) => (
+
+);
diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts
index 9fac9a143c3b3..bbb4faf55e1e9 100644
--- a/x-pack/plugins/lens/public/async_services.ts
+++ b/x-pack/plugins/lens/public/async_services.ts
@@ -24,6 +24,8 @@ export * from './xy_visualization/xy_visualization';
export * from './xy_visualization';
export * from './heatmap_visualization/heatmap_visualization';
export * from './heatmap_visualization';
+export * from './visualizations/gauge/gauge_visualization';
+export * from './visualizations/gauge';
export * from './indexpattern_datasource/indexpattern';
export * from './indexpattern_datasource';
diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx
index 19315b5835d5f..46ca179e7cdb4 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx
@@ -17,7 +17,7 @@ import {
SerializedFieldFormat,
} from 'src/plugins/field_formats/common';
import { VisualizationContainer } from '../../visualization_container';
-import { EmptyPlaceholder } from '../../shared_components';
+import { EmptyPlaceholder } from '../../../../../../src/plugins/charts/public';
import { LensIconChartDatatable } from '../../assets/chart_datatable';
import { DataContext, DatatableComponent } from './table_basic';
import { LensMultiTable } from '../../../common';
diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx
index 7ceffcaaff5db..ba33ce77fc72d 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx
@@ -18,11 +18,12 @@ import {
EuiDataGridSorting,
EuiDataGridStyle,
} from '@elastic/eui';
+import { EmptyPlaceholder } from '../../../../../../src/plugins/charts/public';
import type { LensFilterEvent, LensTableRowContextMenuEvent } from '../../types';
import type { FormatFactory } from '../../../common';
import type { LensGridDirection } from '../../../common/expressions';
import { VisualizationContainer } from '../../visualization_container';
-import { EmptyPlaceholder, findMinMaxByColumnId } from '../../shared_components';
+import { findMinMaxByColumnId } from '../../shared_components';
import { LensIconChartDatatable } from '../../assets/chart_datatable';
import type {
DataContextType,
diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx
index b3aabea46b874..3e8a31fa53915 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx
@@ -138,6 +138,36 @@ describe('Datatable Visualization', () => {
expect(suggestions.length).toBeGreaterThan(0);
});
+ it('should reject suggestion with static value', () => {
+ function staticValueCol(columnId: string): TableSuggestionColumn {
+ return {
+ columnId,
+ operation: {
+ dataType: 'number',
+ label: `Static value: ${columnId}`,
+ isBucketed: false,
+ isStaticValue: true,
+ },
+ };
+ }
+ const suggestions = datatableVisualization.getSuggestions({
+ state: {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ columns: [{ columnId: 'col1' }],
+ },
+ table: {
+ isMultiRow: true,
+ layerId: 'first',
+ changeType: 'initial',
+ columns: [staticValueCol('col1'), strCol('col2')],
+ },
+ keptLayerIds: [],
+ });
+
+ expect(suggestions).toHaveLength(0);
+ });
+
it('should retain width and hidden config from existing state', () => {
const suggestions = datatableVisualization.getSuggestions({
state: {
diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
index 139d85b51cee7..ca8cbbf067b06 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx
@@ -105,7 +105,8 @@ export const getDatatableVisualization = ({
if (
keptLayerIds.length > 1 ||
(keptLayerIds.length && table.layerId !== keptLayerIds[0]) ||
- (state && table.changeType === 'unchanged')
+ (state && table.changeType === 'unchanged') ||
+ table.columns.some((col) => col.operation.isStaticValue)
) {
return [];
}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
index ac79a78ff7d6d..1ba3ff8f6ac34 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
@@ -20,6 +20,63 @@ const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', {
defaultMessage: 'Empty dimension',
});
+interface EmptyButtonProps {
+ columnId: string;
+ onClick: (id: string) => void;
+ group: VisualizationDimensionGroupConfig;
+}
+
+const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => (
+ {
+ onClick(columnId);
+ }}
+ >
+
+
+);
+
+const SuggestedValueButton = ({ columnId, group, onClick }: EmptyButtonProps) => (
+ {
+ onClick(columnId);
+ }}
+ >
+
+
+);
+
export function EmptyDimensionButton({
group,
groups,
@@ -34,12 +91,12 @@ export function EmptyDimensionButton({
layerId: string;
groupIndex: number;
layerIndex: number;
- onClick: (id: string) => void;
onDrop: (
droppedItem: DragDropIdentifier,
dropTarget: DragDropIdentifier,
dropType?: DropType
) => void;
+ onClick: (id: string) => void;
group: VisualizationDimensionGroupConfig;
groups: VisualizationDimensionGroupConfig[];
@@ -105,28 +162,11 @@ export function EmptyDimensionButton({
getCustomDropTarget={getCustomDropTarget}
>
- {
- onClick(value.columnId);
- }}
- >
-
-
+ {typeof group.suggestedValue?.() === 'number' ? (
+
+ ) : (
+
+ )}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
index 84c7722ca1b88..13f9df1739005 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
@@ -406,7 +406,7 @@ export function LayerPanel(
defaultMessage: 'Requires field',
});
- const isOptional = !group.required;
+ const isOptional = !group.required && !group.suggestedValue;
return (
- {' '}
{
).toHaveLength(0);
});
+ test('when metric value isStaticValue', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [
+ {
+ columnId: 'date-column',
+ operation: {
+ isBucketed: true,
+ dataType: 'date',
+ scale: 'interval',
+ label: 'Date',
+ },
+ },
+ {
+ columnId: 'metric-column',
+ operation: {
+ isBucketed: false,
+ dataType: 'number',
+ scale: 'ratio',
+ label: 'Metric',
+ isStaticValue: true,
+ },
+ },
+ {
+ columnId: 'group-column',
+ operation: {
+ isBucketed: true,
+ dataType: 'string',
+ scale: 'ratio',
+ label: 'Group',
+ },
+ },
+ ],
+ changeType: 'initial',
+ },
+ state: {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ } as HeatmapVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([]);
+ });
+
test('when there are 3 or more buckets', () => {
expect(
getSuggestions({
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts
index ebe93419edce6..aeddb8473fa98 100644
--- a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts
+++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts
@@ -19,9 +19,10 @@ export const getSuggestions: Visualization['getSugges
keptLayerIds,
}) => {
if (
- state?.shape === CHART_SHAPES.HEATMAP &&
- (state.xAccessor || state.yAccessor || state.valueAccessor) &&
- table.changeType !== 'extended'
+ (state?.shape === CHART_SHAPES.HEATMAP &&
+ (state.xAccessor || state.yAccessor || state.valueAccessor) &&
+ table.changeType !== 'extended') ||
+ table.columns.some((col) => col.operation.isStaticValue)
) {
return [];
}
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx
index f4d3e11c30cc3..bf645599cae11 100644
--- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx
@@ -58,7 +58,7 @@ function getAxisName(axis: 'x' | 'y') {
}
export const isBucketed = (op: OperationMetadata) => op.isBucketed && op.scale === 'ordinal';
-const isNumericMetric = (op: OperationMetadata) => op.dataType === 'number';
+const isNumericMetric = (op: OperationMetadata) => op.dataType === 'number' && !op.isStaticValue;
export const filterOperationsAxis = (op: OperationMetadata) =>
isBucketed(op) || op.scale === 'interval';
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts
index 08361490cdc2c..fcc9a57285ba6 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts
@@ -10,6 +10,7 @@ import {
isDraggedOperation,
DraggedOperation,
DropType,
+ VisualizationDimensionGroupConfig,
} from '../../../types';
import { getOperationDisplay } from '../../operations';
import { hasField, isDraggedField } from '../../utils';
@@ -36,7 +37,8 @@ const operationLabels = getOperationDisplay();
export function getNewOperation(
field: IndexPatternField | undefined | false,
filterOperations: (meta: OperationMetadata) => boolean,
- targetColumn: GenericIndexPatternColumn
+ targetColumn: GenericIndexPatternColumn,
+ prioritizedOperation?: GenericIndexPatternColumn['operationType']
) {
if (!field) {
return;
@@ -47,7 +49,12 @@ export function getNewOperation(
}
// Detects if we can change the field only, otherwise change field + operation
const shouldOperationPersist = targetColumn && newOperations.includes(targetColumn.operationType);
- return shouldOperationPersist ? targetColumn.operationType : newOperations[0];
+ if (shouldOperationPersist) {
+ return targetColumn.operationType;
+ }
+ const existsPrioritizedOperation =
+ prioritizedOperation && newOperations.includes(prioritizedOperation);
+ return existsPrioritizedOperation ? prioritizedOperation : newOperations[0];
}
export function getField(
@@ -85,7 +92,7 @@ export function getDropProps(props: GetDropProps) {
} else if (hasTheSameField(sourceColumn, targetColumn)) {
return;
} else if (filterOperations(sourceColumn)) {
- return getDropPropsForCompatibleGroup(targetColumn);
+ return getDropPropsForCompatibleGroup(props.dimensionGroups, dragging.columnId, targetColumn);
} else {
return getDropPropsFromIncompatibleGroup({ ...props, dragging });
}
@@ -137,12 +144,26 @@ function getDropPropsForSameGroup(targetColumn?: GenericIndexPatternColumn): Dro
return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] };
}
-function getDropPropsForCompatibleGroup(targetColumn?: GenericIndexPatternColumn): DropProps {
- return {
+function getDropPropsForCompatibleGroup(
+ dimensionGroups: VisualizationDimensionGroupConfig[],
+ sourceId: string,
+ targetColumn?: GenericIndexPatternColumn
+): DropProps {
+ const canSwap =
+ targetColumn &&
+ dimensionGroups
+ .find((group) => group.accessors.some((accessor) => accessor.columnId === sourceId))
+ ?.filterOperations(targetColumn);
+
+ const dropTypes: DropProps = {
dropTypes: targetColumn
- ? ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible']
+ ? ['replace_compatible', 'replace_duplicate_compatible']
: ['move_compatible', 'duplicate_compatible'],
};
+ if (canSwap) {
+ dropTypes.dropTypes.push('swap_compatible');
+ }
+ return dropTypes;
}
function getDropPropsFromIncompatibleGroup({
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts
index b518f667a0bfb..0c538d0fc9486 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts
@@ -70,10 +70,19 @@ function onFieldDrop(props: DropHandlerProps) {
dimensionGroups,
} = props;
+ const prioritizedOperation = dimensionGroups.find(
+ (g) => g.groupId === groupId
+ )?.prioritizedOperation;
+
const layer = state.layers[layerId];
const indexPattern = state.indexPatterns[layer.indexPatternId];
const targetColumn = layer.columns[columnId];
- const newOperation = getNewOperation(droppedItem.field, filterOperations, targetColumn);
+ const newOperation = getNewOperation(
+ droppedItem.field,
+ filterOperations,
+ targetColumn,
+ prioritizedOperation
+ );
if (!isDraggedField(droppedItem) || !newOperation) {
return false;
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
index da5e39c907d07..d7ea174718813 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
@@ -1250,6 +1250,7 @@ describe('IndexPattern Data Source', () => {
label: 'My Op',
dataType: 'string',
isBucketed: true,
+ isStaticValue: false,
} as Operation);
});
@@ -1723,6 +1724,7 @@ describe('IndexPattern Data Source', () => {
...state.layers.first.columns,
newStatic: {
dataType: 'number',
+ isStaticValue: true,
isBucketed: false,
label: 'Static value: 0',
operationType: 'static_value',
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
index 402371930b93e..6179f34226125 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
@@ -68,12 +68,13 @@ export function columnToOperation(
column: GenericIndexPatternColumn,
uniqueLabel?: string
): Operation {
- const { dataType, label, isBucketed, scale } = column;
+ const { dataType, label, isBucketed, scale, operationType } = column;
return {
dataType: normalizeOperationDataType(dataType),
isBucketed,
scale,
label: uniqueLabel || label,
+ isStaticValue: operationType === 'static_value',
};
}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
index a821dcee29d6d..783314968633f 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
@@ -1188,6 +1188,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: '',
scale: undefined,
+ isStaticValue: false,
},
},
{
@@ -1197,6 +1198,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: 'Count of records',
scale: 'ratio',
+ isStaticValue: false,
},
},
],
@@ -1273,6 +1275,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: '',
scale: undefined,
+ isStaticValue: false,
},
},
{
@@ -1282,6 +1285,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: 'Count of records',
scale: 'ratio',
+ isStaticValue: false,
},
},
],
@@ -1546,6 +1550,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'string',
isBucketed: true,
scale: undefined,
+ isStaticValue: false,
},
},
],
@@ -1568,6 +1573,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'string',
isBucketed: true,
scale: undefined,
+ isStaticValue: false,
},
},
],
@@ -1614,6 +1620,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'date',
isBucketed: true,
scale: 'interval',
+ isStaticValue: false,
},
},
{
@@ -1623,6 +1630,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
+ isStaticValue: false,
},
},
],
@@ -1683,6 +1691,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
+ isStaticValue: false,
},
},
{
@@ -1692,6 +1701,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'date',
isBucketed: true,
scale: 'interval',
+ isStaticValue: false,
},
},
{
@@ -1701,6 +1711,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
+ isStaticValue: false,
},
},
],
@@ -1780,6 +1791,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
+ isStaticValue: false,
},
},
{
@@ -1789,6 +1801,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'date',
isBucketed: true,
scale: 'interval',
+ isStaticValue: false,
},
},
{
@@ -1798,6 +1811,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
+ isStaticValue: false,
},
},
],
@@ -1900,6 +1914,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
label: 'My Custom Range',
scale: 'ordinal',
+ isStaticValue: false,
},
},
{
@@ -1909,6 +1924,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
label: 'timestampLabel',
scale: 'interval',
+ isStaticValue: false,
},
},
{
@@ -1918,6 +1934,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: 'Unique count of dest',
scale: undefined,
+ isStaticValue: false,
},
},
],
@@ -2429,6 +2446,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
label: 'My Op',
scale: undefined,
+ isStaticValue: false,
},
},
{
@@ -2438,6 +2456,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
label: 'Top 5',
scale: undefined,
+ isStaticValue: false,
},
},
],
@@ -2501,6 +2520,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
label: 'timestampLabel',
scale: 'interval',
+ isStaticValue: false,
},
},
{
@@ -2510,6 +2530,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: 'Cumulative sum of Records label',
scale: undefined,
+ isStaticValue: false,
},
},
{
@@ -2519,6 +2540,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: 'Cumulative sum of (incomplete)',
scale: undefined,
+ isStaticValue: false,
},
},
],
@@ -2580,6 +2602,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
label: '',
scale: undefined,
+ isStaticValue: false,
},
},
{
@@ -2589,6 +2612,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: '',
scale: undefined,
+ isStaticValue: false,
},
},
{
@@ -2598,6 +2622,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
label: '',
scale: undefined,
+ isStaticValue: false,
},
},
],
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx
index 3e56565b2e13e..6d9a39887b940 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx
@@ -72,6 +72,7 @@ describe('static_value', () => {
dataType: 'number',
isBucketed: false,
operationType: 'static_value',
+ isStaticValue: true,
references: [],
params: {
value: '23',
@@ -106,6 +107,7 @@ describe('static_value', () => {
dataType: 'number',
isBucketed: false,
operationType: 'static_value',
+ isStaticValue: true,
references: [],
params: {
value: '23',
@@ -237,6 +239,7 @@ describe('static_value', () => {
label: 'Static value',
dataType: 'number',
operationType: 'static_value',
+ isStaticValue: true,
isBucketed: false,
scale: 'ratio',
params: { value: '100' },
@@ -253,6 +256,7 @@ describe('static_value', () => {
label: 'Static value',
dataType: 'number',
operationType: 'static_value',
+ isStaticValue: true,
isBucketed: false,
scale: 'ratio',
params: { value: '23' },
@@ -263,6 +267,7 @@ describe('static_value', () => {
label: 'Static value: 23',
dataType: 'number',
operationType: 'static_value',
+ isStaticValue: true,
isBucketed: false,
scale: 'ratio',
params: { value: '23' },
@@ -283,6 +288,7 @@ describe('static_value', () => {
label: 'Static value: 23',
dataType: 'number',
operationType: 'static_value',
+ isStaticValue: true,
isBucketed: false,
scale: 'ratio',
params: { value: '23' },
@@ -300,6 +306,7 @@ describe('static_value', () => {
label: 'Static value',
dataType: 'number',
operationType: 'static_value',
+ isStaticValue: true,
isBucketed: false,
scale: 'ratio',
params: { value: '23' },
@@ -312,6 +319,7 @@ describe('static_value', () => {
label: 'Static value: 53',
dataType: 'number',
operationType: 'static_value',
+ isStaticValue: true,
isBucketed: false,
scale: 'ratio',
params: { value: '53' },
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx
index 45a35d18873fc..0adaf8ea00640 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx
@@ -81,6 +81,7 @@ export const staticValueOperation: OperationDefinition<
dataType: 'number',
isBucketed: false,
scale: 'ratio',
+ isStaticValue: true,
};
},
toExpression: (layer, columnId) => {
@@ -122,6 +123,7 @@ export const staticValueOperation: OperationDefinition<
label: ofName(previousParams.value),
dataType: 'number',
operationType: 'static_value',
+ isStaticValue: true,
isBucketed: false,
scale: 'ratio',
params: { ...previousParams, value: String(previousParams.value ?? defaultValue) },
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts
index 08136ed501cfc..b2cae54b0f8ba 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts
@@ -378,10 +378,6 @@ describe('getOperationTypesForField', () => {
"operationType": "formula",
"type": "managedReference",
},
- Object {
- "operationType": "static_value",
- "type": "managedReference",
- },
],
},
Object {
@@ -398,6 +394,20 @@ describe('getOperationTypesForField', () => {
},
],
},
+ Object {
+ "operationMetaData": Object {
+ "dataType": "number",
+ "isBucketed": false,
+ "isStaticValue": true,
+ "scale": "ratio",
+ },
+ "operations": Array [
+ Object {
+ "operationType": "static_value",
+ "type": "managedReference",
+ },
+ ],
+ },
]
`);
});
diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.tsx
index 4c92864776045..b9a79963510ed 100644
--- a/x-pack/plugins/lens/public/metric_visualization/expression.tsx
+++ b/x-pack/plugins/lens/public/metric_visualization/expression.tsx
@@ -22,7 +22,8 @@ import {
} from '../../../../../src/plugins/charts/public';
import { AutoScale } from './auto_scale';
import { VisualizationContainer } from '../visualization_container';
-import { EmptyPlaceholder, getContrastColor } from '../shared_components';
+import { getContrastColor } from '../shared_components';
+import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import { LensIconChartMetric } from '../assets/chart_metric';
import type { FormatFactory } from '../../common';
import type { MetricChartProps } from '../../common/expressions';
diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts
index 8fceffa0db1fe..7c0f8dd073674 100644
--- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts
+++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts
@@ -31,6 +31,18 @@ describe('metric_suggestions', () => {
};
}
+ function staticValueCol(columnId: string): TableSuggestionColumn {
+ return {
+ columnId,
+ operation: {
+ dataType: 'number',
+ label: `Static value: ${columnId}`,
+ isBucketed: false,
+ isStaticValue: true,
+ },
+ };
+ }
+
function dateCol(columnId: string): TableSuggestionColumn {
return {
columnId,
@@ -86,7 +98,19 @@ describe('metric_suggestions', () => {
).map((table) => expect(getSuggestions({ table, keptLayerIds: ['l1'] })).toEqual([]))
);
});
+ test('does not suggest for a static value', () => {
+ const suggestion = getSuggestions({
+ table: {
+ columns: [staticValueCol('id')],
+ isMultiRow: false,
+ layerId: 'l1',
+ changeType: 'unchanged',
+ },
+ keptLayerIds: [],
+ });
+ expect(suggestion).toHaveLength(0);
+ });
test('suggests a basic metric chart', () => {
const [suggestion, ...rest] = getSuggestions({
table: {
diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts
index 3d6b2683b4ad2..e8a377169bb97 100644
--- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts
+++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts
@@ -26,7 +26,8 @@ export function getSuggestions({
keptLayerIds.length > 1 ||
(keptLayerIds.length && table.layerId !== keptLayerIds[0]) ||
table.columns.length !== 1 ||
- table.columns[0].operation.dataType !== 'number'
+ table.columns[0].operation.dataType !== 'number' ||
+ table.columns[0].operation.isStaticValue
) {
return [];
}
diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx
index 857bfa676faf4..87e51378377aa 100644
--- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx
@@ -90,7 +90,7 @@ export const getMetricVisualization = ({
defaultMessage: 'Metric',
}),
groupLabel: i18n.translate('xpack.lens.metric.groupLabel', {
- defaultMessage: 'Single value',
+ defaultMessage: 'Goal and single value',
}),
sortPriority: 3,
},
diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx
index 55b621498bb10..ef160b1dd682b 100644
--- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx
+++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx
@@ -20,7 +20,7 @@ import type { LensMultiTable } from '../../common';
import type { PieExpressionArgs } from '../../common/expressions';
import { PieComponent } from './render_function';
import { VisualizationContainer } from '../visualization_container';
-import { EmptyPlaceholder } from '../shared_components';
+import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { LensIconChartDonut } from '../assets/chart_donut';
diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx
index 3b9fdaf094822..5841732fb08d1 100644
--- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx
+++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx
@@ -36,7 +36,7 @@ import {
byDataColorPaletteMap,
extractUniqTermsMap,
} from './render_helpers';
-import { EmptyPlaceholder } from '../shared_components';
+import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import './visualization.scss';
import {
ChartsPluginSetup,
diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts
index 92dde282da502..34f2f9defe9b9 100644
--- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts
+++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts
@@ -288,6 +288,35 @@ describe('suggestions', () => {
).toHaveLength(0);
});
+ it('should reject when metric value isStaticValue', () => {
+ const results = suggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [
+ {
+ columnId: 'a',
+ operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true },
+ },
+ {
+ columnId: 'e',
+ operation: {
+ label: 'Count',
+ dataType: 'number' as DataType,
+ isBucketed: false,
+ isStaticValue: true,
+ },
+ },
+ ],
+ changeType: 'initial',
+ },
+ state: undefined,
+ keptLayerIds: ['first'],
+ });
+
+ expect(results.length).toEqual(0);
+ });
+
it('should hide suggestions when there are no buckets', () => {
const currentSuggestions = suggestions({
table: {
diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts
index f638bfd908be4..44cf452a1ac18 100644
--- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts
+++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts
@@ -27,7 +27,8 @@ function shouldReject({ table, keptLayerIds, state }: SuggestionRequest 1 ||
(keptLayerIds.length && table.layerId !== keptLayerIds[0]) ||
table.changeType === 'reorder' ||
- shouldRejectIntervals
+ shouldRejectIntervals ||
+ table.columns.some((col) => col.operation.isStaticValue)
);
}
diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx
index 0eb56ce090aff..df2c2184f084b 100644
--- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx
@@ -41,7 +41,7 @@ function newLayerState(layerId: string): PieLayerState {
const bucketedOperations = (op: OperationMetadata) => op.isBucketed;
const numberMetricOperations = (op: OperationMetadata) =>
- !op.isBucketed && op.dataType === 'number';
+ !op.isBucketed && op.dataType === 'number' && !op.isStaticValue;
const applyPaletteToColumnConfig = (
columns: AccessorConfig[],
diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts
index b89492d7e7588..ecf237ac1327d 100644
--- a/x-pack/plugins/lens/public/plugin.ts
+++ b/x-pack/plugins/lens/public/plugin.ts
@@ -57,6 +57,7 @@ import type {
PieVisualizationPluginSetupPlugins,
} from './pie_visualization';
import type { HeatmapVisualization as HeatmapVisualizationType } from './heatmap_visualization';
+import type { GaugeVisualization as GaugeVisualizationType } from './visualizations/gauge';
import type { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public';
import { AppNavLinkStatus } from '../../../../src/core/public';
@@ -169,6 +170,7 @@ export class LensPlugin {
private metricVisualization: MetricVisualizationType | undefined;
private pieVisualization: PieVisualizationType | undefined;
private heatmapVisualization: HeatmapVisualizationType | undefined;
+ private gaugeVisualization: GaugeVisualizationType | undefined;
private stopReportManager?: () => void;
@@ -308,6 +310,7 @@ export class LensPlugin {
MetricVisualization,
PieVisualization,
HeatmapVisualization,
+ GaugeVisualization,
} = await import('./async_services');
this.datatableVisualization = new DatatableVisualization();
this.editorFrameService = new EditorFrameService();
@@ -316,6 +319,7 @@ export class LensPlugin {
this.metricVisualization = new MetricVisualization();
this.pieVisualization = new PieVisualization();
this.heatmapVisualization = new HeatmapVisualization();
+ this.gaugeVisualization = new GaugeVisualization();
const editorFrameSetupInterface = this.editorFrameService.setup();
@@ -337,6 +341,7 @@ export class LensPlugin {
this.metricVisualization.setup(core, dependencies);
this.pieVisualization.setup(core, dependencies);
this.heatmapVisualization.setup(core, dependencies);
+ this.gaugeVisualization.setup(core, dependencies);
}
start(core: CoreStart, startDependencies: LensPluginStartDependencies): LensPublicStart {
diff --git a/x-pack/plugins/lens/public/shared_components/empty_placeholder.tsx b/x-pack/plugins/lens/public/shared_components/empty_placeholder.tsx
deleted file mode 100644
index ab69c6cc3139d..0000000000000
--- a/x-pack/plugins/lens/public/shared_components/empty_placeholder.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import React from 'react';
-import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui';
-import { FormattedMessage } from '@kbn/i18n-react';
-
-const noResultsMessage = (
-
-);
-
-export const EmptyPlaceholder = ({
- icon,
- message = noResultsMessage,
-}: {
- icon: IconType;
- message?: JSX.Element;
-}) => (
- <>
-
-
-
- {message}
-
- >
-);
diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts
index 9ffddaa1a135b..dd2d5aa7c8558 100644
--- a/x-pack/plugins/lens/public/shared_components/index.ts
+++ b/x-pack/plugins/lens/public/shared_components/index.ts
@@ -5,7 +5,6 @@
* 2.0.
*/
-export * from './empty_placeholder';
export type { ToolbarPopoverProps } from './toolbar_popover';
export { ToolbarPopover } from './toolbar_popover';
export { LegendSettingsPopover } from './legend_settings_popover';
@@ -17,3 +16,4 @@ export * from './helpers';
export { LegendActionPopover } from './legend_action_popover';
export { ValueLabelsSettings } from './value_labels_settings';
export * from './static_header';
+export * from './vis_label';
diff --git a/x-pack/plugins/lens/public/shared_components/vis_label.tsx b/x-pack/plugins/lens/public/shared_components/vis_label.tsx
new file mode 100644
index 0000000000000..2fec7f56561a9
--- /dev/null
+++ b/x-pack/plugins/lens/public/shared_components/vis_label.tsx
@@ -0,0 +1,102 @@
+/*
+ * 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, EuiFieldText, EuiSelect } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+type LabelMode = 'auto' | 'custom' | 'none';
+
+interface Label {
+ mode: LabelMode;
+ label: string;
+}
+
+export interface VisLabelProps {
+ label: string;
+ mode: LabelMode;
+ handleChange: (label: Label) => void;
+ placeholder?: string;
+ hasAutoOption?: boolean;
+ header?: string;
+ dataTestSubj?: string;
+}
+
+const defaultHeader = i18n.translate('xpack.lens.label.header', {
+ defaultMessage: 'Label',
+});
+
+const MODE_NONE = {
+ id: `lns_title_none`,
+ value: 'none',
+ text: i18n.translate('xpack.lens.chart.labelVisibility.none', {
+ defaultMessage: 'None',
+ }),
+};
+
+const MODE_CUSTOM = {
+ id: `lns_title_custom`,
+ value: 'custom',
+ text: i18n.translate('xpack.lens.chart.labelVisibility.custom', {
+ defaultMessage: 'Custom',
+ }),
+};
+
+const MODE_AUTO = {
+ id: `lns_title_auto`,
+ value: 'auto',
+ text: i18n.translate('xpack.lens.chart.labelVisibility.auto', {
+ defaultMessage: 'Auto',
+ }),
+};
+
+const modeDefaultOptions = [MODE_NONE, MODE_CUSTOM];
+
+const modeEnhancedOptions = [MODE_NONE, MODE_AUTO, MODE_CUSTOM];
+
+export function VisLabel({
+ label,
+ mode,
+ handleChange,
+ hasAutoOption = false,
+ placeholder = '',
+ header = defaultHeader,
+ dataTestSubj,
+}: VisLabelProps) {
+ return (
+
+
+ {
+ if (target.value === 'custom') {
+ handleChange({ label: '', mode: target.value as LabelMode });
+ return;
+ }
+ handleChange({ label: '', mode: target.value as LabelMode });
+ }}
+ options={hasAutoOption ? modeEnhancedOptions : modeDefaultOptions}
+ value={mode}
+ />
+
+
+ handleChange({ mode: 'custom', label: target.value })}
+ aria-label={header}
+ />
+
+
+ );
+}
diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts
index af9897581fcf4..67b7ccac97478 100644
--- a/x-pack/plugins/lens/public/state_management/lens_slice.ts
+++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts
@@ -616,7 +616,9 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
visualizationState,
framePublicAPI: {
// any better idea to avoid `as`?
- activeData: state.activeData as TableInspectorAdapter,
+ activeData: state.activeData
+ ? (current(state.activeData) as TableInspectorAdapter)
+ : undefined,
datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap),
},
activeVisualization,
@@ -653,7 +655,9 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
visualizationState: state.visualization.state,
framePublicAPI: {
// any better idea to avoid `as`?
- activeData: state.activeData as TableInspectorAdapter,
+ activeData: state.activeData
+ ? (current(state.activeData) as TableInspectorAdapter)
+ : undefined,
datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap),
},
activeVisualization,
diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts
index da1db7727aff7..8c5331100e903 100644
--- a/x-pack/plugins/lens/public/types.ts
+++ b/x-pack/plugins/lens/public/types.ts
@@ -217,6 +217,7 @@ export interface Datasource {
props: DatasourceDimensionDropProps & {
groupId: string;
dragging: DragContextState['dragging'];
+ prioritizedOperation?: string;
}
) => { dropTypes: DropType[]; nextLabel?: string } | undefined;
onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string };
@@ -431,6 +432,8 @@ export interface OperationMetadata {
// TODO currently it's not possible to differentiate between a field from a raw
// document and an aggregated metric which might be handy in some cases. Once we
// introduce a raw document datasource, this should be considered here.
+
+ isStaticValue?: boolean;
}
export interface VisualizationConfigProps {
@@ -475,6 +478,8 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
required?: boolean;
requiredMinDimensionCount?: number;
dataTestSubj?: string;
+ prioritizedOperation?: string;
+ suggestedValue?: () => number | undefined;
/**
* When the dimension editor is enabled for this group, all dimensions in the group
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/__snapshots__/chart_component.test.tsx.snap b/x-pack/plugins/lens/public/visualizations/gauge/__snapshots__/chart_component.test.tsx.snap
new file mode 100644
index 0000000000000..b588c1d341a75
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/__snapshots__/chart_component.test.tsx.snap
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`GaugeComponent renders the chart 1`] = `
+
+
+
+
+`;
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/chart_component.test.tsx b/x-pack/plugins/lens/public/visualizations/gauge/chart_component.test.tsx
new file mode 100644
index 0000000000000..517c5718f613f
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/chart_component.test.tsx
@@ -0,0 +1,429 @@
+/*
+ * 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 { Chart, Goal } from '@elastic/charts';
+import { shallowWithIntl } from '@kbn/test/jest';
+import { chartPluginMock } from 'src/plugins/charts/public/mocks';
+import type { ColorStop, LensMultiTable } from '../../../common';
+import { fieldFormatsServiceMock } from '../../../../../../src/plugins/field_formats/public/mocks';
+import { GaugeArguments, GaugeLabelMajorMode } from '../../../common/expressions/gauge_chart';
+import { GaugeComponent, GaugeRenderProps } from './chart_component';
+import { DatatableColumn, DatatableRow } from 'src/plugins/expressions/common';
+import { VisualizationContainer } from '../../visualization_container';
+
+jest.mock('@elastic/charts', () => {
+ const original = jest.requireActual('@elastic/charts');
+
+ return {
+ ...original,
+ getSpecId: jest.fn(() => {}),
+ };
+});
+
+const numberColumn = (id = 'metric-accessor'): DatatableColumn => ({
+ id,
+ name: 'Count of records',
+ meta: {
+ type: 'number',
+ index: 'kibana_sample_data_ecommerce',
+ params: {
+ id: 'number',
+ },
+ },
+});
+
+const createData = (
+ row: DatatableRow = { 'metric-accessor': 3, 'min-accessor': 0, 'max-accessor': 10 }
+): LensMultiTable => {
+ return {
+ type: 'lens_multitable',
+ tables: {
+ layerId: {
+ type: 'datatable',
+ rows: [row],
+ columns: Object.keys(row).map((key) => numberColumn(key)),
+ },
+ },
+ };
+};
+
+const chartsThemeService = chartPluginMock.createSetupContract().theme;
+const palettesRegistry = chartPluginMock.createPaletteRegistry();
+const formatService = fieldFormatsServiceMock.createStartContract();
+const args: GaugeArguments = {
+ labelMajor: 'Gauge',
+ description: 'vis description',
+ metricAccessor: 'metric-accessor',
+ minAccessor: '',
+ maxAccessor: '',
+ goalAccessor: '',
+ shape: 'verticalBullet',
+ colorMode: 'none',
+ ticksPosition: 'auto',
+ labelMajorMode: 'auto',
+};
+
+describe('GaugeComponent', function () {
+ let wrapperProps: GaugeRenderProps;
+
+ beforeAll(() => {
+ wrapperProps = {
+ data: createData(),
+ chartsThemeService,
+ args,
+ paletteService: palettesRegistry,
+ formatFactory: formatService.deserialize,
+ };
+ });
+
+ it('renders the chart', () => {
+ const component = shallowWithIntl();
+ expect(component.find(Chart)).toMatchSnapshot();
+ });
+
+ it('shows empty placeholder when metricAccessor is not provided', async () => {
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: undefined,
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ },
+ data: createData({ 'min-accessor': 0, 'max-accessor': 10 }),
+ };
+ const component = shallowWithIntl();
+ expect(component.find(VisualizationContainer)).toHaveLength(1);
+ });
+
+ it('shows empty placeholder when minimum accessor equals maximum accessor', async () => {
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ },
+ data: createData({ 'metric-accessor': 0, 'min-accessor': 0, 'max-accessor': 0 }),
+ };
+ const component = shallowWithIntl();
+ expect(component.find('EmptyPlaceholder')).toHaveLength(1);
+ });
+ it('shows empty placeholder when minimum accessor value is greater maximum accessor value', async () => {
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ },
+ data: createData({ 'metric-accessor': 0, 'min-accessor': 0, 'max-accessor': -10 }),
+ };
+ const component = shallowWithIntl();
+ expect(component.find('EmptyPlaceholder')).toHaveLength(1);
+ });
+ it('when metric value is bigger than max, it takes maximum value', () => {
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ ticksPosition: 'bands',
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ },
+ data: createData({ 'metric-accessor': 12, 'min-accessor': 0, 'max-accessor': 10 }),
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('actual')).toEqual(10);
+ });
+
+ describe('labelMajor and labelMinor settings', () => {
+ it('displays no labelMajor and no labelMinor when no passed', () => {
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ labelMajorMode: 'none' as GaugeLabelMajorMode,
+ labelMinor: '',
+ },
+ };
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('labelMajor')).toEqual('');
+ expect(goal.prop('labelMinor')).toEqual('');
+ });
+ it('displays custom labelMajor and labelMinor when passed', () => {
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ labelMajorMode: 'custom' as GaugeLabelMajorMode,
+ labelMajor: 'custom labelMajor',
+ labelMinor: 'custom labelMinor',
+ },
+ };
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('labelMajor')).toEqual('custom labelMajor ');
+ expect(goal.prop('labelMinor')).toEqual('custom labelMinor ');
+ });
+ it('displays auto labelMajor', () => {
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ labelMajorMode: 'auto' as GaugeLabelMajorMode,
+ labelMajor: '',
+ },
+ };
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('labelMajor')).toEqual('Count of records ');
+ });
+ });
+
+ describe('ticks and color bands', () => {
+ it('displays auto ticks', () => {
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ },
+ };
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 3.33, 6.67, 10]);
+ });
+ it('spreads auto ticks only over the [min, max] domain if color bands defined bigger domain', () => {
+ const palette = {
+ type: 'palette' as const,
+ name: 'custom',
+ params: {
+ colors: ['#aaa', '#bbb', '#ccc'],
+ gradient: false,
+ stops: [10, 20, 30] as unknown as ColorStop[],
+ range: 'number',
+ rangeMin: 0,
+ rangeMax: 20,
+ },
+ };
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ palette,
+ },
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 3.33, 6.67, 10]);
+ });
+ it('sets proper color bands and ticks on color bands for values smaller than maximum', () => {
+ const palette = {
+ type: 'palette' as const,
+ name: 'custom',
+ params: {
+ colors: ['#aaa', '#bbb', '#ccc'],
+ gradient: false,
+ stops: [1, 2, 3] as unknown as ColorStop[],
+ range: 'number',
+ rangeMin: 0,
+ rangeMax: 4,
+ },
+ };
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ palette,
+ ticksPosition: 'bands',
+ },
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 1, 2, 3, 10]);
+ expect(goal.prop('bands')).toEqual([0, 1, 2, 3, 10]);
+ });
+ it('sets proper color bands and ticks on color bands if palette steps are smaller than minimum', () => {
+ const palette = {
+ type: 'palette' as const,
+ name: 'custom',
+ params: {
+ colors: ['#aaa', '#bbb', '#ccc'],
+ gradient: false,
+ stops: [-10, -5, 0] as unknown as ColorStop[],
+ range: 'number',
+ rangeMin: 0,
+ rangeMax: 4,
+ },
+ };
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ palette,
+ ticksPosition: 'bands',
+ },
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 10]);
+ expect(goal.prop('bands')).toEqual([0, 10]);
+ });
+ it('sets proper color bands and ticks on color bands if percent palette steps are smaller than 0', () => {
+ const palette = {
+ type: 'palette' as const,
+ name: 'custom',
+ params: {
+ colors: ['#aaa', '#bbb', '#ccc'],
+ gradient: false,
+ stops: [-20, -60, 80],
+ range: 'percent',
+ rangeMin: 0,
+ rangeMax: 4,
+ },
+ };
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ palette,
+ ticksPosition: 'bands',
+ },
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 8, 10]);
+ expect(goal.prop('bands')).toEqual([0, 8, 10]);
+ });
+ it('doesnt set ticks for values differing <10%', () => {
+ const palette = {
+ type: 'palette' as const,
+ name: 'custom',
+ params: {
+ colors: ['#aaa', '#bbb', '#ccc'],
+ gradient: false,
+ stops: [1, 1.5, 3] as unknown as ColorStop[],
+ range: 'number',
+ rangeMin: 0,
+ rangeMax: 10,
+ },
+ };
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ palette,
+ ticksPosition: 'bands',
+ },
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 1, 3, 10]);
+ expect(goal.prop('bands')).toEqual([0, 1, 1.5, 3, 10]);
+ });
+ it('sets proper color bands and ticks on color bands for values greater than maximum', () => {
+ const palette = {
+ type: 'palette' as const,
+ name: 'custom',
+ params: {
+ colors: ['#aaa', '#bbb', '#ccc'],
+ gradient: false,
+ stops: [10, 20, 30, 31] as unknown as ColorStop[],
+ range: 'number',
+ rangeMin: 0,
+ rangeMax: 30,
+ },
+ };
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ palette,
+ ticksPosition: 'bands',
+ },
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 10]);
+ expect(goal.prop('bands')).toEqual([0, 10]);
+ });
+ it('passes number bands from color palette with no stops defined', () => {
+ const palette = {
+ type: 'palette' as const,
+ name: 'gray',
+ params: {
+ colors: ['#aaa', '#bbb'],
+ gradient: false,
+ stops: [],
+ range: 'number',
+ rangeMin: 0,
+ rangeMax: 10,
+ },
+ };
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ colorMode: 'palette',
+ palette,
+ ticksPosition: 'bands',
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ },
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 5, 10]);
+ expect(goal.prop('bands')).toEqual([0, 5, 10]);
+ });
+ it('passes percent bands from color palette', () => {
+ const palette = {
+ type: 'palette' as const,
+ name: 'custom',
+ params: {
+ colors: ['#aaa', '#bbb', '#ccc'],
+ gradient: false,
+ stops: [20, 60, 80],
+ range: 'percent',
+ rangeMin: 0,
+ rangeMax: 10,
+ },
+ };
+ const customProps = {
+ ...wrapperProps,
+ args: {
+ ...wrapperProps.args,
+ colorMode: 'palette',
+ palette,
+ ticksPosition: 'bands',
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ },
+ } as GaugeRenderProps;
+ const goal = shallowWithIntl().find(Goal);
+ expect(goal.prop('ticks')).toEqual([0, 2, 6, 8, 10]);
+ expect(goal.prop('bands')).toEqual([0, 2, 6, 8, 10]);
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/chart_component.tsx b/x-pack/plugins/lens/public/visualizations/gauge/chart_component.tsx
new file mode 100644
index 0000000000000..a8f2b0e1c204c
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/chart_component.tsx
@@ -0,0 +1,244 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { FC } from 'react';
+import { Chart, Goal, Settings } from '@elastic/charts';
+import { FormattedMessage } from '@kbn/i18n-react';
+import type {
+ CustomPaletteState,
+ ChartsPluginSetup,
+ PaletteRegistry,
+} from 'src/plugins/charts/public';
+import { VisualizationContainer } from '../../visualization_container';
+import './index.scss';
+import { LensIconChartGaugeHorizontal, LensIconChartGaugeVertical } from '../../assets/chart_gauge';
+import { EmptyPlaceholder } from '../../../../../../src/plugins/charts/public';
+import { getMaxValue, getMinValue, getValueFromAccessor } from './utils';
+import {
+ GaugeExpressionProps,
+ GaugeShapes,
+ GaugeTicksPosition,
+ GaugeTicksPositions,
+ GaugeLabelMajorMode,
+} from '../../../common/expressions/gauge_chart';
+import type { FormatFactory } from '../../../common';
+
+export type GaugeRenderProps = GaugeExpressionProps & {
+ formatFactory: FormatFactory;
+ chartsThemeService: ChartsPluginSetup['theme'];
+ paletteService: PaletteRegistry;
+};
+
+declare global {
+ interface Window {
+ /**
+ * Flag used to enable debugState on elastic charts
+ */
+ _echDebugStateFlag?: boolean;
+ }
+}
+
+function normalizeColors({ colors, stops, range }: CustomPaletteState, min: number) {
+ if (!colors) {
+ return;
+ }
+ const colorsOutOfRangeSmaller = Math.max(
+ stops.filter((stop, i) => (range === 'percent' ? stop < 0 : stop < min)).length,
+ 0
+ );
+ return colors.slice(colorsOutOfRangeSmaller);
+}
+
+function normalizeBands(
+ { colors, stops, range }: CustomPaletteState,
+ { min, max }: { min: number; max: number }
+) {
+ if (!stops.length) {
+ const step = (max - min) / colors.length;
+ return [min, ...colors.map((_, i) => min + (i + 1) * step)];
+ }
+ if (range === 'percent') {
+ const filteredStops = stops.filter((stop) => stop >= 0 && stop <= 100);
+ return [min, ...filteredStops.map((step) => min + step * ((max - min) / 100)), max];
+ }
+ const orderedStops = stops.filter((stop, i) => stop < max && stop > min);
+ return [min, ...orderedStops, max];
+}
+
+function getTitle(
+ labelMajorMode: GaugeLabelMajorMode,
+ labelMajor?: string,
+ fallbackTitle?: string
+) {
+ if (labelMajorMode === 'none') {
+ return '';
+ } else if (labelMajorMode === 'auto') {
+ return `${fallbackTitle || ''} `;
+ }
+ return `${labelMajor || fallbackTitle || ''} `;
+}
+
+// TODO: once charts handle not displaying labels when there's no space for them, it's safe to remove this
+function getTicksLabels(baseStops: number[]) {
+ const tenPercentRange = (Math.max(...baseStops) - Math.min(...baseStops)) * 0.1;
+ const lastIndex = baseStops.length - 1;
+ return baseStops.filter((stop, i) => {
+ if (i === 0 || i === lastIndex) {
+ return true;
+ }
+
+ return !(
+ stop - baseStops[i - 1] < tenPercentRange || baseStops[lastIndex] - stop < tenPercentRange
+ );
+ });
+}
+
+function getTicks(
+ ticksPosition: GaugeTicksPosition,
+ range: [number, number],
+ colorBands?: number[]
+) {
+ if (ticksPosition === GaugeTicksPositions.bands && colorBands) {
+ return colorBands && getTicksLabels(colorBands);
+ }
+ const TICKS_NO = 3;
+ const min = Math.min(...(colorBands || []), ...range);
+ const max = Math.max(...(colorBands || []), ...range);
+ const step = (max - min) / TICKS_NO;
+ return [
+ ...Array(TICKS_NO)
+ .fill(null)
+ .map((_, i) => Number((min + step * i).toFixed(2))),
+ max,
+ ];
+}
+
+export const GaugeComponent: FC = ({
+ data,
+ args,
+ formatFactory,
+ chartsThemeService,
+}) => {
+ const {
+ shape: subtype,
+ metricAccessor,
+ palette,
+ colorMode,
+ labelMinor,
+ labelMajor,
+ labelMajorMode,
+ ticksPosition,
+ } = args;
+ if (!metricAccessor) {
+ return ;
+ }
+
+ const chartTheme = chartsThemeService.useChartsTheme();
+
+ const table = Object.values(data.tables)[0];
+ const metricColumn = table.columns.find((col) => col.id === metricAccessor);
+
+ const chartData = table.rows.filter(
+ (v) => typeof v[metricAccessor!] === 'number' || Array.isArray(v[metricAccessor!])
+ );
+ const row = chartData?.[0];
+
+ const metricValue = getValueFromAccessor('metricAccessor', row, args);
+
+ const icon =
+ subtype === GaugeShapes.horizontalBullet
+ ? LensIconChartGaugeHorizontal
+ : LensIconChartGaugeVertical;
+
+ if (typeof metricValue !== 'number') {
+ return ;
+ }
+
+ const goal = getValueFromAccessor('goalAccessor', row, args);
+ const min = getMinValue(row, args);
+ const max = getMaxValue(row, args);
+
+ if (min === max) {
+ return (
+
+ }
+ />
+ );
+ } else if (min > max) {
+ return (
+
+ }
+ />
+ );
+ }
+
+ const tickFormatter = formatFactory(
+ metricColumn?.meta?.params?.params
+ ? metricColumn?.meta?.params
+ : {
+ id: 'number',
+ params: {
+ pattern: max - min > 5 ? `0,0` : `0,0.0`,
+ },
+ }
+ );
+ const colors = palette?.params?.colors ? normalizeColors(palette.params, min) : undefined;
+ const bands: number[] = (palette?.params as CustomPaletteState)
+ ? normalizeBands(args.palette?.params as CustomPaletteState, { min, max })
+ : [min, max];
+
+ // TODO: format in charts
+ const formattedActual = Math.round(Math.min(Math.max(metricValue, min), max) * 1000) / 1000;
+
+ return (
+
+
+ = min && goal <= max ? goal : undefined}
+ actual={formattedActual}
+ tickValueFormatter={({ value: tickValue }) => tickFormatter.convert(tickValue)}
+ bands={bands}
+ ticks={getTicks(ticksPosition, [min, max], bands)}
+ bandFillColor={
+ colorMode === 'palette' && colors
+ ? (val) => {
+ const index = bands && bands.indexOf(val.value) - 1;
+ return colors && index >= 0 && colors[index]
+ ? colors[index]
+ : colors[colors.length - 1];
+ }
+ : () => `rgba(255,255,255,0)`
+ }
+ labelMajor={getTitle(labelMajorMode, labelMajor, metricColumn?.name)}
+ labelMinor={labelMinor ? labelMinor + ' ' : ''}
+ />
+
+ );
+};
+
+export function GaugeChartReportable(props: GaugeRenderProps) {
+ return (
+
+
+
+ );
+}
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/constants.ts b/x-pack/plugins/lens/public/visualizations/gauge/constants.ts
new file mode 100644
index 0000000000000..1a801dc942652
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/constants.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export const LENS_GAUGE_RENDERER = 'lens_gauge_renderer';
+export const LENS_GAUGE_ID = 'lnsGauge';
+
+export const GROUP_ID = {
+ METRIC: 'metric',
+ MIN: 'min',
+ MAX: 'max',
+ GOAL: 'goal',
+} as const;
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.scss b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.scss
new file mode 100644
index 0000000000000..d7664b9d2da16
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.scss
@@ -0,0 +1,3 @@
+.lnsDynamicColoringRow {
+ align-items: center;
+}
\ No newline at end of file
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx
new file mode 100644
index 0000000000000..89a4be3300e2e
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx
@@ -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.
+ */
+
+import {
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiColorPaletteDisplay,
+ EuiFormRow,
+ EuiFlexItem,
+ EuiSwitchEvent,
+ EuiSwitch,
+} from '@elastic/eui';
+import React, { useState } from 'react';
+import { i18n } from '@kbn/i18n';
+import type { PaletteRegistry } from 'src/plugins/charts/public';
+import {
+ isNumericFieldForDatatable,
+ GaugeVisualizationState,
+ GaugeTicksPositions,
+ GaugeColorModes,
+} from '../../../common/expressions';
+import {
+ applyPaletteParams,
+ CustomizablePalette,
+ CUSTOM_PALETTE,
+ FIXED_PROGRESSION,
+ getStopsForFixedMode,
+ PalettePanelContainer,
+} from '../../shared_components/';
+import type { VisualizationDimensionEditorProps } from '../../types';
+import { defaultPaletteParams } from './palette_config';
+
+import './dimension_editor.scss';
+import { getMaxValue, getMinValue } from './utils';
+
+export function GaugeDimensionEditor(
+ props: VisualizationDimensionEditorProps & {
+ paletteService: PaletteRegistry;
+ }
+) {
+ const { state, setState, frame, accessor } = props;
+ const [isPaletteOpen, setIsPaletteOpen] = useState(false);
+
+ if (state?.metricAccessor !== accessor) return null;
+
+ const currentData = frame.activeData?.[state.layerId];
+ const [firstRow] = currentData?.rows || [];
+
+ if (accessor == null || firstRow == null || !isNumericFieldForDatatable(currentData, accessor)) {
+ return null;
+ }
+
+ const hasDynamicColoring = state?.colorMode === 'palette';
+
+ const currentMinMax = {
+ min: getMinValue(firstRow, state),
+ max: getMaxValue(firstRow, state),
+ };
+
+ const activePalette = state?.palette || {
+ type: 'palette',
+ name: defaultPaletteParams.name,
+ params: {
+ ...defaultPaletteParams,
+ colorStops: undefined,
+ stops: undefined,
+ rangeMin: currentMinMax.min,
+ rangeMax: (currentMinMax.max * 3) / 4,
+ },
+ };
+
+ const displayStops = applyPaletteParams(props.paletteService, activePalette, currentMinMax);
+
+ const togglePalette = () => setIsPaletteOpen(!isPaletteOpen);
+ return (
+ <>
+
+ {
+ const { checked } = e.target;
+ const params = checked
+ ? {
+ palette: {
+ ...activePalette,
+ params: {
+ ...activePalette.params,
+ stops: displayStops,
+ },
+ },
+ ticksPosition: GaugeTicksPositions.bands,
+ colorMode: GaugeColorModes.palette,
+ }
+ : {
+ ticksPosition: GaugeTicksPositions.auto,
+ colorMode: GaugeColorModes.none,
+ };
+
+ setState({
+ ...state,
+ ...params,
+ });
+ }}
+ />
+
+ {hasDynamicColoring && (
+ <>
+
+
+
+ color)
+ }
+ type={FIXED_PROGRESSION}
+ onClick={togglePalette}
+ />
+
+
+
+ {i18n.translate('xpack.lens.paletteTableGradient.customize', {
+ defaultMessage: 'Edit',
+ })}
+
+
+ {
+ // if the new palette is not custom, replace the rangeMin with the artificial one
+ if (
+ newPalette.name !== CUSTOM_PALETTE &&
+ newPalette.params &&
+ newPalette.params.rangeMin !== currentMinMax.min
+ ) {
+ newPalette.params.rangeMin = currentMinMax.min;
+ }
+ setState({
+ ...state,
+ palette: newPalette,
+ });
+ }}
+ />
+
+
+
+
+
+ {
+ setState({
+ ...state,
+ ticksPosition:
+ state.ticksPosition === GaugeTicksPositions.bands
+ ? GaugeTicksPositions.auto
+ : GaugeTicksPositions.bands,
+ });
+ }}
+ />
+
+ >
+ )}
+ >
+ );
+}
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/expression.tsx b/x-pack/plugins/lens/public/visualizations/gauge/expression.tsx
new file mode 100644
index 0000000000000..b8852f22691ed
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/expression.tsx
@@ -0,0 +1,60 @@
+/*
+ * 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 { I18nProvider } from '@kbn/i18n-react';
+import ReactDOM from 'react-dom';
+import React from 'react';
+import type { IInterpreterRenderHandlers } from '../../../../../../src/plugins/expressions';
+import type { FormatFactory } from '../../../common';
+import { LENS_GAUGE_RENDERER } from './constants';
+import type {
+ ChartsPluginSetup,
+ PaletteRegistry,
+} from '../../../../../../src/plugins/charts/public';
+import { GaugeChartReportable } from './chart_component';
+import type { GaugeExpressionProps } from '../../../common/expressions';
+
+export const getGaugeRenderer = (dependencies: {
+ formatFactory: FormatFactory;
+ chartsThemeService: ChartsPluginSetup['theme'];
+ paletteService: PaletteRegistry;
+}) => ({
+ name: LENS_GAUGE_RENDERER,
+ displayName: i18n.translate('xpack.lens.gauge.visualizationName', {
+ defaultMessage: 'Gauge',
+ }),
+ help: '',
+ validate: () => undefined,
+ reuseDomNode: true,
+ render: async (
+ domNode: Element,
+ config: GaugeExpressionProps,
+ handlers: IInterpreterRenderHandlers
+ ) => {
+ ReactDOM.render(
+
+ {
+
+ }
+ ,
+ domNode,
+ () => {
+ handlers.done();
+ }
+ );
+
+ handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode));
+ },
+});
+
+const MemoizedChart = React.memo(GaugeChartReportable);
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/gauge_visualization.ts b/x-pack/plugins/lens/public/visualizations/gauge/gauge_visualization.ts
new file mode 100644
index 0000000000000..231b6bacbbe20
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/gauge_visualization.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export * from './expression';
+export * from './visualization';
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/index.scss b/x-pack/plugins/lens/public/visualizations/gauge/index.scss
new file mode 100644
index 0000000000000..c999fe7e218a2
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/index.scss
@@ -0,0 +1,14 @@
+.lnsGaugeExpression__container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ text-align: center;
+ overflow-x: hidden;
+
+ .echChart {
+ width: 100%;
+ }
+}
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/index.ts b/x-pack/plugins/lens/public/visualizations/gauge/index.ts
new file mode 100644
index 0000000000000..b0a4f26f2d675
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/index.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 type { CoreSetup } from 'kibana/public';
+import type { ExpressionsSetup } from '../../../../../../src/plugins/expressions/public';
+import type { EditorFrameSetup } from '../../types';
+import type { ChartsPluginSetup } from '../../../../../../src/plugins/charts/public';
+import type { FormatFactory } from '../../../common';
+import { transparentizePalettes } from './utils';
+
+export interface GaugeVisualizationPluginSetupPlugins {
+ expressions: ExpressionsSetup;
+ formatFactory: FormatFactory;
+ editorFrame: EditorFrameSetup;
+ charts: ChartsPluginSetup;
+}
+
+export class GaugeVisualization {
+ setup(
+ core: CoreSetup,
+ { expressions, formatFactory, editorFrame, charts }: GaugeVisualizationPluginSetupPlugins
+ ) {
+ editorFrame.registerVisualization(async () => {
+ const { getGaugeVisualization, getGaugeRenderer } = await import('../../async_services');
+ const palettes = transparentizePalettes(await charts.palettes.getPalettes());
+
+ expressions.registerRenderer(
+ getGaugeRenderer({
+ formatFactory,
+ chartsThemeService: charts.theme,
+ paletteService: palettes,
+ })
+ );
+ return getGaugeVisualization({ paletteService: palettes });
+ });
+ }
+}
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/palette_config.tsx b/x-pack/plugins/lens/public/visualizations/gauge/palette_config.tsx
new file mode 100644
index 0000000000000..20e026a3f5719
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/palette_config.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { RequiredPaletteParamTypes } from '../../../common';
+import { defaultPaletteParams as sharedDefaultParams } from '../../shared_components/';
+
+export const DEFAULT_PALETTE_NAME = 'gray';
+export const DEFAULT_COLOR_STEPS = 3;
+export const DEFAULT_MIN_STOP = 0;
+export const DEFAULT_MAX_STOP = 100;
+
+export const defaultPaletteParams: RequiredPaletteParamTypes = {
+ ...sharedDefaultParams,
+ rangeMin: DEFAULT_MIN_STOP,
+ rangeMax: DEFAULT_MAX_STOP,
+ name: DEFAULT_PALETTE_NAME,
+ steps: DEFAULT_COLOR_STEPS,
+ maxSteps: 5,
+};
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/suggestions.test.ts b/x-pack/plugins/lens/public/visualizations/gauge/suggestions.test.ts
new file mode 100644
index 0000000000000..cced4bb2c309b
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/suggestions.test.ts
@@ -0,0 +1,213 @@
+/*
+ * 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 { getSuggestions } from './suggestions';
+import { GaugeShapes, GaugeVisualizationState } from '../../../common/expressions';
+import { layerTypes } from '../../../common';
+
+const metricColumn = {
+ columnId: 'metric-column',
+ operation: {
+ isBucketed: false,
+ dataType: 'number' as const,
+ scale: 'ratio' as const,
+ label: 'Metric',
+ },
+};
+
+const bucketColumn = {
+ columnId: 'date-column-01',
+ operation: {
+ isBucketed: true,
+ dataType: 'date' as const,
+ scale: 'interval' as const,
+ label: 'Date',
+ },
+};
+
+describe('gauge suggestions', () => {
+ describe('rejects suggestions', () => {
+ test('when currently active and unchanged data', () => {
+ const unchangedSuggestion = {
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [],
+ changeType: 'unchanged' as const,
+ },
+ state: {
+ shape: GaugeShapes.horizontalBullet,
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ } as GaugeVisualizationState,
+ keptLayerIds: ['first'],
+ };
+ expect(getSuggestions(unchangedSuggestion)).toHaveLength(0);
+ });
+ test('when there are buckets', () => {
+ const bucketAndMetricSuggestion = {
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [bucketColumn, metricColumn],
+ changeType: 'initial' as const,
+ },
+ state: {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ } as GaugeVisualizationState,
+ keptLayerIds: ['first'],
+ };
+ expect(getSuggestions(bucketAndMetricSuggestion)).toEqual([]);
+ });
+ test('when currently active with partial configuration', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [metricColumn],
+ changeType: 'initial',
+ },
+ state: {
+ shape: GaugeShapes.horizontalBullet,
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ minAccessor: 'some-field',
+ labelMajorMode: 'auto',
+ ticksPosition: 'auto',
+ } as GaugeVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toHaveLength(0);
+ });
+ test('for tables with a single bucket dimension', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [bucketColumn],
+ changeType: 'reduced',
+ },
+ state: {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ } as GaugeVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([]);
+ });
+ test('when two metric accessor are available', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [
+ metricColumn,
+ {
+ ...metricColumn,
+ columnId: 'metric-column2',
+ },
+ ],
+ changeType: 'initial',
+ },
+ state: {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ } as GaugeVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([]);
+ });
+ });
+});
+
+describe('shows suggestions', () => {
+ test('when complete configuration has been resolved', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [metricColumn],
+ changeType: 'initial',
+ },
+ state: {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ } as GaugeVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([
+ {
+ state: {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ shape: GaugeShapes.horizontalBullet,
+ metricAccessor: 'metric-column',
+ labelMajorMode: 'auto',
+ ticksPosition: 'auto',
+ },
+ title: 'Gauge',
+ hide: true,
+ previewIcon: 'empty',
+ score: 0.5,
+ },
+ {
+ hide: true,
+ previewIcon: 'empty',
+ title: 'Gauge',
+ score: 0.5,
+ state: {
+ layerId: 'first',
+ layerType: 'data',
+ metricAccessor: 'metric-column',
+ shape: GaugeShapes.verticalBullet,
+ ticksPosition: 'auto',
+ labelMajorMode: 'auto',
+ },
+ },
+ ]);
+ });
+ test('passes the state when change is shape change', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [metricColumn],
+ changeType: 'extended',
+ },
+ state: {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ shape: GaugeShapes.horizontalBullet,
+ metricAccessor: 'metric-column',
+ } as GaugeVisualizationState,
+ keptLayerIds: ['first'],
+ subVisualizationId: GaugeShapes.verticalBullet,
+ })
+ ).toEqual([
+ {
+ state: {
+ layerType: layerTypes.DATA,
+ shape: GaugeShapes.verticalBullet,
+ metricAccessor: 'metric-column',
+ labelMajorMode: 'auto',
+ ticksPosition: 'auto',
+ layerId: 'first',
+ },
+ previewIcon: 'empty',
+ title: 'Gauge',
+ hide: false, // shows suggestion when current is gauge
+ score: 0.5,
+ },
+ ]);
+ });
+});
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/suggestions.ts b/x-pack/plugins/lens/public/visualizations/gauge/suggestions.ts
new file mode 100644
index 0000000000000..03198411581c1
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/suggestions.ts
@@ -0,0 +1,108 @@
+/*
+ * 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 type { TableSuggestion, Visualization } from '../../types';
+import { layerTypes } from '../../../common';
+import {
+ GaugeShape,
+ GaugeShapes,
+ GaugeTicksPositions,
+ GaugeLabelMajorModes,
+ GaugeVisualizationState,
+} from '../../../common/expressions/gauge_chart';
+
+const isNotNumericMetric = (table: TableSuggestion) =>
+ table.columns?.[0]?.operation.dataType !== 'number' ||
+ table.columns.some((col) => col.operation.isBucketed);
+
+const hasLayerMismatch = (keptLayerIds: string[], table: TableSuggestion) =>
+ keptLayerIds.length > 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]);
+
+export const getSuggestions: Visualization['getSuggestions'] = ({
+ table,
+ state,
+ keptLayerIds,
+ subVisualizationId,
+}) => {
+ const isGauge = Boolean(
+ state && (state.minAccessor || state.maxAccessor || state.goalAccessor || state.metricAccessor)
+ );
+
+ const numberOfAccessors =
+ state &&
+ [state.minAccessor, state.maxAccessor, state.goalAccessor, state.metricAccessor].filter(Boolean)
+ .length;
+
+ if (
+ hasLayerMismatch(keptLayerIds, table) ||
+ isNotNumericMetric(table) ||
+ (!isGauge && table.columns.length > 1) ||
+ (isGauge && (numberOfAccessors !== table.columns.length || table.changeType === 'initial'))
+ ) {
+ return [];
+ }
+
+ const shape: GaugeShape =
+ state?.shape === GaugeShapes.verticalBullet
+ ? GaugeShapes.verticalBullet
+ : GaugeShapes.horizontalBullet;
+
+ const baseSuggestion = {
+ state: {
+ ...state,
+ shape,
+ layerId: table.layerId,
+ layerType: layerTypes.DATA,
+ ticksPosition: GaugeTicksPositions.auto,
+ labelMajorMode: GaugeLabelMajorModes.auto,
+ },
+ title: i18n.translate('xpack.lens.gauge.gaugeLabel', {
+ defaultMessage: 'Gauge',
+ }),
+ previewIcon: 'empty',
+ score: 0.5,
+ hide: !isGauge || state?.metricAccessor === undefined, // only display for gauges for beta
+ };
+
+ const suggestions = isGauge
+ ? [
+ {
+ ...baseSuggestion,
+ state: {
+ ...baseSuggestion.state,
+ ...state,
+ shape:
+ state?.shape === GaugeShapes.verticalBullet
+ ? GaugeShapes.horizontalBullet
+ : GaugeShapes.verticalBullet,
+ },
+ },
+ ]
+ : [
+ {
+ ...baseSuggestion,
+ state: {
+ ...baseSuggestion.state,
+ metricAccessor: table.columns[0].columnId,
+ },
+ },
+ {
+ ...baseSuggestion,
+ state: {
+ ...baseSuggestion.state,
+ metricAccessor: table.columns[0].columnId,
+ shape:
+ state?.shape === GaugeShapes.verticalBullet
+ ? GaugeShapes.horizontalBullet
+ : GaugeShapes.verticalBullet,
+ },
+ },
+ ];
+
+ return suggestions;
+};
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_config_panel.scss b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_config_panel.scss
new file mode 100644
index 0000000000000..893ed71235881
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_config_panel.scss
@@ -0,0 +1,3 @@
+.lnsGaugeToolbar__popover {
+ width: 500px;
+}
\ No newline at end of file
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_toolbar.test.tsx b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_toolbar.test.tsx
new file mode 100644
index 0000000000000..2d3d54be97453
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_toolbar.test.tsx
@@ -0,0 +1,179 @@
+/*
+ * 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, { FormEvent } from 'react';
+import { mountWithIntl } from '@kbn/test/jest';
+import { GaugeToolbar } from '.';
+import { FramePublicAPI, VisualizationToolbarProps } from '../../../types';
+import { ToolbarButton } from 'src/plugins/kibana_react/public';
+import { ReactWrapper } from 'enzyme';
+import { GaugeVisualizationState } from '../../../../common/expressions';
+import { act } from 'react-dom/test-utils';
+
+jest.mock('lodash', () => {
+ const original = jest.requireActual('lodash');
+
+ return {
+ ...original,
+ debounce: (fn: unknown) => fn,
+ };
+});
+
+class Harness {
+ wrapper: ReactWrapper;
+
+ constructor(wrapper: ReactWrapper) {
+ this.wrapper = wrapper;
+ }
+
+ togglePopover() {
+ this.wrapper.find(ToolbarButton).simulate('click');
+ }
+
+ public get titleLabel() {
+ return this.wrapper.find('EuiFieldText[data-test-subj="lnsToolbarGaugeLabelMajor"]');
+ }
+ public get titleSelect() {
+ return this.wrapper.find('EuiSelect[data-test-subj="lnsToolbarGaugeLabelMajor-select"]');
+ }
+
+ modifyTitle(e: FormEvent) {
+ act(() => {
+ this.titleLabel.prop('onChange')!(e);
+ });
+ }
+
+ public get subtitleSelect() {
+ return this.wrapper.find('EuiSelect[data-test-subj="lnsToolbarGaugeLabelMinor-select"]');
+ }
+
+ public get subtitleLabel() {
+ return this.wrapper.find('EuiFieldText[data-test-subj="lnsToolbarGaugeLabelMinor"]');
+ }
+
+ modifySubtitle(e: FormEvent) {
+ act(() => {
+ this.subtitleLabel.prop('onChange')!(e);
+ });
+ }
+}
+
+describe('gauge toolbar', () => {
+ let harness: Harness;
+ let defaultProps: VisualizationToolbarProps;
+
+ beforeEach(() => {
+ defaultProps = {
+ setState: jest.fn(),
+ frame: {} as FramePublicAPI,
+ state: {
+ layerId: 'layerId',
+ layerType: 'data',
+ metricAccessor: 'metric-accessor',
+ minAccessor: '',
+ maxAccessor: '',
+ goalAccessor: '',
+ shape: 'verticalBullet',
+ colorMode: 'none',
+ ticksPosition: 'auto',
+ labelMajorMode: 'auto',
+ },
+ };
+ });
+
+ it('should reflect state in the UI for default props', async () => {
+ harness = new Harness(mountWithIntl());
+ harness.togglePopover();
+
+ expect(harness.titleLabel.prop('value')).toBe('');
+ expect(harness.titleSelect.prop('value')).toBe('auto');
+ expect(harness.subtitleLabel.prop('value')).toBe('');
+ expect(harness.subtitleSelect.prop('value')).toBe('none');
+ });
+ it('should reflect state in the UI for non-default props', async () => {
+ const props = {
+ ...defaultProps,
+ state: {
+ ...defaultProps.state,
+ ticksPosition: 'bands' as const,
+ labelMajorMode: 'custom' as const,
+ labelMajor: 'new labelMajor',
+ labelMinor: 'new labelMinor',
+ },
+ };
+
+ harness = new Harness(mountWithIntl());
+ harness.togglePopover();
+
+ expect(harness.titleLabel.prop('value')).toBe('new labelMajor');
+ expect(harness.titleSelect.prop('value')).toBe('custom');
+ expect(harness.subtitleLabel.prop('value')).toBe('new labelMinor');
+ expect(harness.subtitleSelect.prop('value')).toBe('custom');
+ });
+
+ describe('labelMajor', () => {
+ it('labelMajor label is disabled if labelMajor is selected to be none', () => {
+ defaultProps.state.labelMajorMode = 'none' as const;
+
+ harness = new Harness(mountWithIntl());
+ harness.togglePopover();
+
+ expect(harness.titleSelect.prop('value')).toBe('none');
+ expect(harness.titleLabel.prop('disabled')).toBe(true);
+ expect(harness.titleLabel.prop('value')).toBe('');
+ });
+ it('labelMajor mode switches to custom when user starts typing', () => {
+ defaultProps.state.labelMajorMode = 'auto' as const;
+
+ harness = new Harness(mountWithIntl());
+ harness.togglePopover();
+
+ expect(harness.titleSelect.prop('value')).toBe('auto');
+ expect(harness.titleLabel.prop('disabled')).toBe(false);
+ expect(harness.titleLabel.prop('value')).toBe('');
+ harness.modifyTitle({ target: { value: 'labelMajor' } } as unknown as FormEvent);
+ expect(defaultProps.setState).toHaveBeenCalledTimes(1);
+ expect(defaultProps.setState).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ labelMajorMode: 'custom',
+ labelMajor: 'labelMajor',
+ })
+ );
+ });
+ });
+ describe('labelMinor', () => {
+ it('labelMinor label is enabled if labelMinor is string', () => {
+ defaultProps.state.labelMinor = 'labelMinor label';
+
+ harness = new Harness(mountWithIntl());
+ harness.togglePopover();
+
+ expect(harness.subtitleSelect.prop('value')).toBe('custom');
+ expect(harness.subtitleLabel.prop('disabled')).toBe(false);
+ expect(harness.subtitleLabel.prop('value')).toBe('labelMinor label');
+ });
+ it('labelMajor mode can switch to custom', () => {
+ defaultProps.state.labelMinor = '';
+
+ harness = new Harness(mountWithIntl());
+ harness.togglePopover();
+
+ expect(harness.subtitleSelect.prop('value')).toBe('none');
+ expect(harness.subtitleLabel.prop('disabled')).toBe(true);
+ expect(harness.subtitleLabel.prop('value')).toBe('');
+ harness.modifySubtitle({ target: { value: 'labelMinor label' } } as unknown as FormEvent);
+ expect(defaultProps.setState).toHaveBeenCalledTimes(1);
+ expect(defaultProps.setState).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ labelMinor: 'labelMinor label',
+ })
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/index.tsx b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/index.tsx
new file mode 100644
index 0000000000000..e907dc0529019
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/index.tsx
@@ -0,0 +1,99 @@
+/*
+ * 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, { memo, useState } from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import type { VisualizationToolbarProps } from '../../../types';
+import { ToolbarPopover, useDebouncedValue, VisLabel } from '../../../shared_components';
+import './gauge_config_panel.scss';
+import { GaugeLabelMajorMode, GaugeVisualizationState } from '../../../../common/expressions';
+
+export const GaugeToolbar = memo((props: VisualizationToolbarProps) => {
+ const { state, setState, frame } = props;
+ const metricDimensionTitle =
+ state.layerId &&
+ frame.activeData?.[state.layerId]?.columns.find((col) => col.id === state.metricAccessor)?.name;
+
+ const [subtitleMode, setSubtitleMode] = useState(() =>
+ state.labelMinor ? 'custom' : 'none'
+ );
+
+ const { inputValue, handleInputChange } = useDebouncedValue({
+ onChange: setState,
+ value: state,
+ });
+
+ return (
+
+
+
+ {
+ setSubtitleMode(inputValue.labelMinor ? 'custom' : 'none');
+ }}
+ title={i18n.translate('xpack.lens.gauge.appearanceLabel', {
+ defaultMessage: 'Appearance',
+ })}
+ type="visualOptions"
+ buttonDataTestSubj="lnsVisualOptionsButton"
+ panelClassName="lnsGaugeToolbar__popover"
+ >
+
+ {
+ handleInputChange({
+ ...inputValue,
+ labelMajor: value.label,
+ labelMajorMode: value.mode,
+ });
+ }}
+ />
+
+
+ {
+ handleInputChange({
+ ...inputValue,
+ labelMinor: value.label,
+ });
+ setSubtitleMode(value.mode);
+ }}
+ />
+
+
+
+
+
+ );
+});
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/utils.test.ts b/x-pack/plugins/lens/public/visualizations/gauge/utils.test.ts
new file mode 100644
index 0000000000000..8989c5fa46934
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/utils.test.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { transparentizePalettes } from './utils';
+import { chartPluginMock } from '../../../../../../src/plugins/charts/public/mocks';
+
+const paletteServiceMock = chartPluginMock.createPaletteRegistry();
+
+describe('transparentizePalettes', () => {
+ it('converts all colors to half-transparent', () => {
+ const newPalettes = transparentizePalettes(paletteServiceMock);
+
+ const singlePalette = newPalettes.get('mocked');
+ expect(singlePalette.getCategoricalColors(2)).toEqual(['#0000FF80', '#FFFF0080']);
+ expect(
+ singlePalette.getCategoricalColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ ])
+ ).toEqual('#0000FF80');
+
+ const firstPalette = newPalettes.getAll()[0];
+ expect(firstPalette.getCategoricalColors(2)).toEqual(['#FF000080', '#00000080']);
+ expect(
+ firstPalette.getCategoricalColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ ])
+ ).toEqual('#00000080');
+ });
+});
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/utils.ts b/x-pack/plugins/lens/public/visualizations/gauge/utils.ts
new file mode 100644
index 0000000000000..ec6e52b01864b
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/utils.ts
@@ -0,0 +1,113 @@
+/*
+ * 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 { scaleLinear } from 'd3-scale';
+import {
+ ChartColorConfiguration,
+ PaletteDefinition,
+ PaletteRegistry,
+ SeriesLayer,
+} from 'src/plugins/charts/public';
+import { DatatableRow } from 'src/plugins/expressions';
+import Color from 'color';
+import type { GaugeVisualizationState } from '../../../common/expressions/gauge_chart';
+
+type GaugeAccessors = 'maxAccessor' | 'minAccessor' | 'goalAccessor' | 'metricAccessor';
+
+type GaugeAccessorsType = Pick;
+
+export const getValueFromAccessor = (
+ accessorName: GaugeAccessors,
+ row?: DatatableRow,
+ state?: GaugeAccessorsType
+) => {
+ if (row && state) {
+ const accessor = state[accessorName];
+ const value = accessor && row[accessor];
+ if (typeof value === 'number') {
+ return value;
+ }
+ if (value?.length) {
+ if (typeof value[value.length - 1] === 'number') {
+ return value[value.length - 1];
+ }
+ }
+ }
+};
+
+export const getMaxValue = (row?: DatatableRow, state?: GaugeAccessorsType): number => {
+ const FALLBACK_VALUE = 100;
+ const currentValue = getValueFromAccessor('maxAccessor', row, state);
+ if (currentValue != null) {
+ return currentValue;
+ }
+ if (row && state) {
+ const { metricAccessor, goalAccessor } = state;
+ const metricValue = metricAccessor && row[metricAccessor];
+ const goalValue = goalAccessor && row[goalAccessor];
+ const minValue = getMinValue(row, state);
+ if (metricValue != null) {
+ const numberValues = [minValue, goalValue, metricValue].filter((v) => typeof v === 'number');
+ const biggerValue = Math.max(...numberValues);
+ const nicelyRounded = scaleLinear().domain([minValue, biggerValue]).nice().ticks(4);
+ if (nicelyRounded.length > 2) {
+ const ticksDifference = Math.abs(nicelyRounded[0] - nicelyRounded[1]);
+ return nicelyRounded[nicelyRounded.length - 1] + ticksDifference;
+ }
+ return minValue === biggerValue ? biggerValue + 1 : biggerValue;
+ }
+ }
+ return FALLBACK_VALUE;
+};
+
+export const getMinValue = (row?: DatatableRow, state?: GaugeAccessorsType) => {
+ const currentValue = getValueFromAccessor('minAccessor', row, state);
+ if (currentValue != null) {
+ return currentValue;
+ }
+ const FALLBACK_VALUE = 0;
+ if (row && state) {
+ const { metricAccessor, maxAccessor } = state;
+ const metricValue = metricAccessor && row[metricAccessor];
+ const maxValue = maxAccessor && row[maxAccessor];
+ const numberValues = [metricValue, maxValue].filter((v) => typeof v === 'number');
+ if (Math.min(...numberValues) <= 0) {
+ return Math.min(...numberValues) - 10; // TODO: TO THINK THROUGH
+ }
+ }
+ return FALLBACK_VALUE;
+};
+
+export const getGoalValue = (row?: DatatableRow, state?: GaugeVisualizationState) => {
+ const currentValue = getValueFromAccessor('goalAccessor', row, state);
+ if (currentValue != null) {
+ return currentValue;
+ }
+ const minValue = getMinValue(row, state);
+ const maxValue = getMaxValue(row, state);
+ return Math.round((maxValue - minValue) * 0.75 + minValue);
+};
+
+export const transparentizePalettes = (palettes: PaletteRegistry) => {
+ const addAlpha = (c: string | null) => (c ? new Color(c).hex() + `80` : `000000`);
+ const transparentizePalette = (palette: PaletteDefinition) => ({
+ ...palette,
+ getCategoricalColor: (
+ series: SeriesLayer[],
+ chartConfiguration?: ChartColorConfiguration,
+ state?: unknown
+ ) => addAlpha(palette.getCategoricalColor(series, chartConfiguration, state)),
+ getCategoricalColors: (size: number, state?: unknown): string[] =>
+ palette.getCategoricalColors(size, state).map(addAlpha),
+ });
+
+ return {
+ ...palettes,
+ get: (name: string) => transparentizePalette(palettes.get(name)),
+ getAll: () => palettes.getAll().map((singlePalette) => transparentizePalette(singlePalette)),
+ };
+};
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts
new file mode 100644
index 0000000000000..e5e7f092db9ce
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts
@@ -0,0 +1,590 @@
+/*
+ * 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 { getGaugeVisualization, isNumericDynamicMetric, isNumericMetric } from './visualization';
+import { createMockDatasource, createMockFramePublicAPI } from '../../mocks';
+import { GROUP_ID } from './constants';
+import type { DatasourcePublicAPI, Operation } from '../../types';
+import { chartPluginMock } from 'src/plugins/charts/public/mocks';
+import { CustomPaletteParams, layerTypes } from '../../../common';
+import {
+ EXPRESSION_GAUGE_NAME,
+ GaugeVisualizationState,
+} from '../../../common/expressions/gauge_chart';
+import { PaletteOutput } from 'src/plugins/charts/common';
+
+function exampleState(): GaugeVisualizationState {
+ return {
+ layerId: 'test-layer',
+ layerType: layerTypes.DATA,
+ labelMajorMode: 'auto',
+ ticksPosition: 'auto',
+ shape: 'horizontalBullet',
+ };
+}
+
+const paletteService = chartPluginMock.createPaletteRegistry();
+
+describe('gauge', () => {
+ let frame: ReturnType;
+
+ beforeEach(() => {
+ frame = createMockFramePublicAPI();
+ });
+
+ describe('#intialize', () => {
+ test('returns a default state', () => {
+ expect(getGaugeVisualization({ paletteService }).initialize(() => 'l1')).toEqual({
+ layerId: 'l1',
+ layerType: layerTypes.DATA,
+ title: 'Empty Gauge chart',
+ shape: 'horizontalBullet',
+ labelMajorMode: 'auto',
+ ticksPosition: 'auto',
+ });
+ });
+
+ test('returns persisted state', () => {
+ expect(
+ getGaugeVisualization({ paletteService }).initialize(() => 'test-layer', exampleState())
+ ).toEqual(exampleState());
+ });
+ });
+
+ describe('#getConfiguration', () => {
+ beforeEach(() => {
+ const mockDatasource = createMockDatasource('testDatasource');
+
+ mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
+ dataType: 'string',
+ label: 'MyOperation',
+ } as Operation);
+
+ frame.datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ });
+
+ afterEach(() => {
+ // some tests manipulate it, so restore a pristine version
+ frame = createMockFramePublicAPI();
+ });
+
+ test('resolves configuration from complete state and available data', () => {
+ const state: GaugeVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ goalAccessor: 'goal-accessor',
+ };
+ frame.activeData = {
+ first: { type: 'datatable', columns: [], rows: [{ 'metric-accessor': 200 }] },
+ };
+
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getConfiguration({ state, frame, layerId: 'first' })
+ ).toEqual({
+ groups: [
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.METRIC,
+ groupLabel: 'Metric',
+ accessors: [{ columnId: 'metric-accessor', triggerIcon: 'none' }],
+ filterOperations: isNumericDynamicMetric,
+ supportsMoreColumns: false,
+ required: true,
+ dataTestSubj: 'lnsGauge_metricDimensionPanel',
+ enableDimensionEditor: true,
+ supportFieldFormat: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.MIN,
+ groupLabel: 'Minimum value',
+ accessors: [{ columnId: 'min-accessor' }],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: false,
+ dataTestSubj: 'lnsGauge_minDimensionPanel',
+ prioritizedOperation: 'min',
+ suggestedValue: expect.any(Function),
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.MAX,
+ groupLabel: 'Maximum value',
+ accessors: [{ columnId: 'max-accessor' }],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: false,
+ dataTestSubj: 'lnsGauge_maxDimensionPanel',
+ prioritizedOperation: 'max',
+ suggestedValue: expect.any(Function),
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.GOAL,
+ groupLabel: 'Goal value',
+ accessors: [{ columnId: 'goal-accessor' }],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: false,
+ required: false,
+ dataTestSubj: 'lnsGauge_goalDimensionPanel',
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ ],
+ });
+ });
+
+ test('resolves configuration from partial state', () => {
+ const state: GaugeVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ minAccessor: 'min-accessor',
+ };
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getConfiguration({ state, frame, layerId: 'first' })
+ ).toEqual({
+ groups: [
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.METRIC,
+ groupLabel: 'Metric',
+ accessors: [],
+ filterOperations: isNumericDynamicMetric,
+ supportsMoreColumns: true,
+ required: true,
+ dataTestSubj: 'lnsGauge_metricDimensionPanel',
+ enableDimensionEditor: true,
+ supportFieldFormat: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.MIN,
+ groupLabel: 'Minimum value',
+ accessors: [{ columnId: 'min-accessor' }],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: false,
+ dataTestSubj: 'lnsGauge_minDimensionPanel',
+ prioritizedOperation: 'min',
+ suggestedValue: expect.any(Function),
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.MAX,
+ groupLabel: 'Maximum value',
+ accessors: [],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: true,
+ dataTestSubj: 'lnsGauge_maxDimensionPanel',
+ prioritizedOperation: 'max',
+ suggestedValue: expect.any(Function),
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.GOAL,
+ groupLabel: 'Goal value',
+ accessors: [],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: true,
+ required: false,
+ dataTestSubj: 'lnsGauge_goalDimensionPanel',
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ ],
+ });
+ });
+
+ test("resolves configuration when there's no access to active data in frame", () => {
+ const state: GaugeVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ goalAccessor: 'goal-accessor',
+ };
+
+ frame.activeData = undefined;
+
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getConfiguration({ state, frame, layerId: 'first' })
+ ).toEqual({
+ groups: [
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.METRIC,
+ groupLabel: 'Metric',
+ accessors: [{ columnId: 'metric-accessor', triggerIcon: 'none' }],
+ filterOperations: isNumericDynamicMetric,
+ supportsMoreColumns: false,
+ required: true,
+ dataTestSubj: 'lnsGauge_metricDimensionPanel',
+ enableDimensionEditor: true,
+ supportFieldFormat: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.MIN,
+ groupLabel: 'Minimum value',
+ accessors: [{ columnId: 'min-accessor' }],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: false,
+ dataTestSubj: 'lnsGauge_minDimensionPanel',
+ prioritizedOperation: 'min',
+ suggestedValue: expect.any(Function),
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.MAX,
+ groupLabel: 'Maximum value',
+ accessors: [{ columnId: 'max-accessor' }],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: false,
+ dataTestSubj: 'lnsGauge_maxDimensionPanel',
+ prioritizedOperation: 'max',
+ suggestedValue: expect.any(Function),
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.GOAL,
+ groupLabel: 'Goal value',
+ accessors: [{ columnId: 'goal-accessor' }],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: false,
+ required: false,
+ dataTestSubj: 'lnsGauge_goalDimensionPanel',
+ supportFieldFormat: false,
+ supportStaticValue: true,
+ },
+ ],
+ });
+ });
+ });
+
+ describe('#setDimension', () => {
+ test('set dimension correctly', () => {
+ const prevState: GaugeVisualizationState = {
+ ...exampleState(),
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ };
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).setDimension({
+ prevState,
+ layerId: 'first',
+ columnId: 'new-min-accessor',
+ groupId: 'min',
+ frame,
+ })
+ ).toEqual({
+ ...prevState,
+ minAccessor: 'new-min-accessor',
+ });
+ });
+ });
+
+ describe('#removeDimension', () => {
+ const prevState: GaugeVisualizationState = {
+ ...exampleState(),
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ palette: [] as unknown as PaletteOutput,
+ colorMode: 'palette',
+ ticksPosition: 'bands',
+ };
+ test('removes metricAccessor correctly', () => {
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).removeDimension({
+ prevState,
+ layerId: 'first',
+ columnId: 'metric-accessor',
+ frame,
+ })
+ ).toEqual({
+ ...exampleState(),
+ minAccessor: 'min-accessor',
+ });
+ });
+ test('removes minAccessor correctly', () => {
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).removeDimension({
+ prevState,
+ layerId: 'first',
+ columnId: 'min-accessor',
+ frame,
+ })
+ ).toEqual({
+ ...exampleState(),
+ metricAccessor: 'metric-accessor',
+ palette: [] as unknown as PaletteOutput,
+ colorMode: 'palette',
+ ticksPosition: 'bands',
+ });
+ });
+ });
+ describe('#getSupportedLayers', () => {
+ it('should return a single layer type', () => {
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getSupportedLayers()
+ ).toHaveLength(1);
+ });
+ });
+ describe('#getLayerType', () => {
+ it('should return the type only if the layer is in the state', () => {
+ const state: GaugeVisualizationState = {
+ ...exampleState(),
+ minAccessor: 'minAccessor',
+ goalAccessor: 'value-accessor',
+ };
+ const instance = getGaugeVisualization({
+ paletteService,
+ });
+ expect(instance.getLayerType('test-layer', state)).toEqual(layerTypes.DATA);
+ expect(instance.getLayerType('foo', state)).toBeUndefined();
+ });
+ });
+
+ describe('#toExpression', () => {
+ let datasourceLayers: Record;
+ beforeEach(() => {
+ const mockDatasource = createMockDatasource('testDatasource');
+ mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
+ dataType: 'string',
+ label: 'MyOperation',
+ } as Operation);
+ datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ });
+ test('creates an expression based on state and attributes', () => {
+ const state: GaugeVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ minAccessor: 'min-accessor',
+ goalAccessor: 'goal-accessor',
+ metricAccessor: 'metric-accessor',
+ maxAccessor: 'max-accessor',
+ labelMinor: 'Subtitle',
+ };
+ const attributes = {
+ title: 'Test',
+ };
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).toExpression(state, datasourceLayers, attributes)
+ ).toEqual({
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: EXPRESSION_GAUGE_NAME,
+ arguments: {
+ title: ['Test'],
+ description: [''],
+ metricAccessor: ['metric-accessor'],
+ minAccessor: ['min-accessor'],
+ maxAccessor: ['max-accessor'],
+ goalAccessor: ['goal-accessor'],
+ colorMode: ['none'],
+ ticksPosition: ['auto'],
+ labelMajorMode: ['auto'],
+ labelMinor: ['Subtitle'],
+ labelMajor: [],
+ palette: [],
+ shape: ['horizontalBullet'],
+ },
+ },
+ ],
+ });
+ });
+ test('returns null with a missing metric accessor', () => {
+ const state: GaugeVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ minAccessor: 'minAccessor',
+ };
+ const attributes = {
+ title: 'Test',
+ };
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).toExpression(state, datasourceLayers, attributes)
+ ).toEqual(null);
+ });
+ });
+
+ describe('#getErrorMessages', () => {
+ it('returns undefined if no error is raised', () => {
+ const error = getGaugeVisualization({
+ paletteService,
+ }).getErrorMessages(exampleState());
+ expect(error).not.toBeDefined();
+ });
+ });
+
+ describe('#getWarningMessages', () => {
+ beforeEach(() => {
+ const mockDatasource = createMockDatasource('testDatasource');
+ mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
+ dataType: 'string',
+ label: 'MyOperation',
+ } as Operation);
+ frame.datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ });
+ const state: GaugeVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ metricAccessor: 'metric-accessor',
+ minAccessor: 'min-accessor',
+ maxAccessor: 'max-accessor',
+ goalAccessor: 'goal-accessor',
+ };
+ it('should not warn for data in bounds', () => {
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ columns: [],
+ rows: [
+ {
+ 'min-accessor': 0,
+ 'metric-accessor': 5,
+ 'max-accessor': 10,
+ 'goal-accessor': 8,
+ },
+ ],
+ },
+ };
+
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getWarningMessages!(state, frame)
+ ).toHaveLength(0);
+ });
+ it('should warn when minimum value is greater than metric value', () => {
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ columns: [],
+ rows: [
+ {
+ 'metric-accessor': -1,
+ 'min-accessor': 1,
+ 'max-accessor': 3,
+ 'goal-accessor': 2,
+ },
+ ],
+ },
+ };
+
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getWarningMessages!(state, frame)
+ ).toHaveLength(1);
+ });
+
+ it('should warn when metric value is greater than maximum value', () => {
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ columns: [],
+ rows: [
+ {
+ 'metric-accessor': 10,
+ 'min-accessor': -10,
+ 'max-accessor': 0,
+ },
+ ],
+ },
+ };
+
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getWarningMessages!(state, frame)
+ ).toHaveLength(1);
+ });
+ it('should warn when goal value is greater than maximum value', () => {
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ columns: [],
+ rows: [
+ {
+ 'metric-accessor': 5,
+ 'min-accessor': 0,
+ 'max-accessor': 10,
+ 'goal-accessor': 15,
+ },
+ ],
+ },
+ };
+
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getWarningMessages!(state, frame)
+ ).toHaveLength(1);
+ });
+ it('should warn when minimum value is greater than goal value', () => {
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ columns: [],
+ rows: [
+ {
+ 'metric-accessor': 5,
+ 'min-accessor': 0,
+ 'max-accessor': 10,
+ 'goal-accessor': -5,
+ },
+ ],
+ },
+ };
+
+ expect(
+ getGaugeVisualization({
+ paletteService,
+ }).getWarningMessages!(state, frame)
+ ).toHaveLength(1);
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx
new file mode 100644
index 0000000000000..59dd2ac161a8a
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx
@@ -0,0 +1,454 @@
+/*
+ * 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 { render } from 'react-dom';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage, I18nProvider } from '@kbn/i18n-react';
+import { Ast } from '@kbn/interpreter/common';
+import { PaletteRegistry } from '../../../../../../src/plugins/charts/public';
+import type { DatasourcePublicAPI, OperationMetadata, Visualization } from '../../types';
+import { getSuggestions } from './suggestions';
+import { GROUP_ID, LENS_GAUGE_ID } from './constants';
+import { GaugeToolbar } from './toolbar_component';
+import { LensIconChartGaugeHorizontal, LensIconChartGaugeVertical } from '../../assets/chart_gauge';
+import { applyPaletteParams, CUSTOM_PALETTE, getStopsForFixedMode } from '../../shared_components';
+import { GaugeDimensionEditor } from './dimension_editor';
+import { CustomPaletteParams, layerTypes } from '../../../common';
+import { generateId } from '../../id_generator';
+import { getGoalValue, getMaxValue, getMinValue } from './utils';
+
+import {
+ GaugeShapes,
+ GaugeArguments,
+ EXPRESSION_GAUGE_NAME,
+ GaugeVisualizationState,
+} from '../../../common/expressions/gauge_chart';
+
+const groupLabelForGauge = i18n.translate('xpack.lens.metric.groupLabel', {
+ defaultMessage: 'Goal and single value',
+});
+
+interface GaugeVisualizationDeps {
+ paletteService: PaletteRegistry;
+}
+
+export const isNumericMetric = (op: OperationMetadata) =>
+ !op.isBucketed && op.dataType === 'number';
+
+export const isNumericDynamicMetric = (op: OperationMetadata) =>
+ isNumericMetric(op) && !op.isStaticValue;
+
+export const CHART_NAMES = {
+ horizontalBullet: {
+ icon: LensIconChartGaugeHorizontal,
+ label: i18n.translate('xpack.lens.gaugeHorizontal.gaugeLabel', {
+ defaultMessage: 'Gauge horizontal',
+ }),
+ groupLabel: groupLabelForGauge,
+ },
+ verticalBullet: {
+ icon: LensIconChartGaugeVertical,
+ label: i18n.translate('xpack.lens.gaugeVertical.gaugeLabel', {
+ defaultMessage: 'Gauge vertical',
+ }),
+ groupLabel: groupLabelForGauge,
+ },
+};
+
+function computePaletteParams(params: CustomPaletteParams) {
+ return {
+ ...params,
+ // rewrite colors and stops as two distinct arguments
+ colors: (params?.stops || []).map(({ color }) => color),
+ stops: params?.name === 'custom' ? (params?.stops || []).map(({ stop }) => stop) : [],
+ reverse: false, // managed at UI level
+ };
+}
+
+const toExpression = (
+ paletteService: PaletteRegistry,
+ state: GaugeVisualizationState,
+ datasourceLayers: Record,
+ attributes?: Partial>
+): Ast | null => {
+ const datasource = datasourceLayers[state.layerId];
+
+ const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId);
+ if (!originalOrder || !state.metricAccessor) {
+ return null;
+ }
+
+ return {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: EXPRESSION_GAUGE_NAME,
+ arguments: {
+ title: [attributes?.title ?? ''],
+ description: [attributes?.description ?? ''],
+ metricAccessor: [state.metricAccessor ?? ''],
+ minAccessor: [state.minAccessor ?? ''],
+ maxAccessor: [state.maxAccessor ?? ''],
+ goalAccessor: [state.goalAccessor ?? ''],
+ shape: [state.shape ?? GaugeShapes.horizontalBullet],
+ colorMode: [state?.colorMode ?? 'none'],
+ palette: state.palette?.params
+ ? [
+ paletteService
+ .get(CUSTOM_PALETTE)
+ .toExpression(
+ computePaletteParams((state.palette?.params || {}) as CustomPaletteParams)
+ ),
+ ]
+ : [],
+ ticksPosition: state.ticksPosition ? [state.ticksPosition] : ['auto'],
+ labelMinor: state.labelMinor ? [state.labelMinor] : [],
+ labelMajor: state.labelMajor ? [state.labelMajor] : [],
+ labelMajorMode: state.labelMajorMode ? [state.labelMajorMode] : ['auto'],
+ },
+ },
+ ],
+ };
+};
+
+export const getGaugeVisualization = ({
+ paletteService,
+}: GaugeVisualizationDeps): Visualization => ({
+ id: LENS_GAUGE_ID,
+
+ visualizationTypes: [
+ {
+ ...CHART_NAMES.horizontalBullet,
+ id: GaugeShapes.horizontalBullet,
+ showExperimentalBadge: true,
+ },
+ {
+ ...CHART_NAMES.verticalBullet,
+ id: GaugeShapes.verticalBullet,
+ showExperimentalBadge: true,
+ },
+ ],
+ getVisualizationTypeId(state) {
+ return state.shape;
+ },
+ getLayerIds(state) {
+ return [state.layerId];
+ },
+ clearLayer(state) {
+ const newState = { ...state };
+ delete newState.metricAccessor;
+ delete newState.minAccessor;
+ delete newState.maxAccessor;
+ delete newState.goalAccessor;
+ delete newState.palette;
+ delete newState.colorMode;
+ return newState;
+ },
+
+ getDescription(state) {
+ if (state.shape === GaugeShapes.horizontalBullet) {
+ return CHART_NAMES.horizontalBullet;
+ }
+ return CHART_NAMES.verticalBullet;
+ },
+
+ switchVisualizationType: (visualizationTypeId, state) => {
+ return {
+ ...state,
+ shape:
+ visualizationTypeId === GaugeShapes.horizontalBullet
+ ? GaugeShapes.horizontalBullet
+ : GaugeShapes.verticalBullet,
+ };
+ },
+
+ initialize(addNewLayer, state, mainPalette) {
+ return (
+ state || {
+ layerId: addNewLayer(),
+ layerType: layerTypes.DATA,
+ title: 'Empty Gauge chart',
+ shape: GaugeShapes.horizontalBullet,
+ palette: mainPalette,
+ ticksPosition: 'auto',
+ labelMajorMode: 'auto',
+ }
+ );
+ },
+ getSuggestions,
+
+ getConfiguration({ state, frame }) {
+ const hasColoring = Boolean(state.colorMode !== 'none' && state.palette?.params?.stops);
+
+ const row = state?.layerId ? frame?.activeData?.[state?.layerId]?.rows?.[0] : undefined;
+ let palette;
+ if (!(row == null || state?.metricAccessor == null || state?.palette == null || !hasColoring)) {
+ const currentMinMax = { min: getMinValue(row, state), max: getMaxValue(row, state) };
+
+ const displayStops = applyPaletteParams(paletteService, state?.palette, currentMinMax);
+ palette = getStopsForFixedMode(displayStops, state?.palette?.params?.colorStops);
+ }
+
+ return {
+ groups: [
+ {
+ supportFieldFormat: true,
+ layerId: state.layerId,
+ groupId: GROUP_ID.METRIC,
+ groupLabel: i18n.translate('xpack.lens.gauge.metricLabel', {
+ defaultMessage: 'Metric',
+ }),
+ accessors: state.metricAccessor
+ ? [
+ palette
+ ? {
+ columnId: state.metricAccessor,
+ triggerIcon: 'colorBy',
+ palette,
+ }
+ : {
+ columnId: state.metricAccessor,
+ triggerIcon: 'none',
+ },
+ ]
+ : [],
+ filterOperations: isNumericDynamicMetric,
+ supportsMoreColumns: !state.metricAccessor,
+ required: true,
+ dataTestSubj: 'lnsGauge_metricDimensionPanel',
+ enableDimensionEditor: true,
+ },
+ {
+ supportStaticValue: true,
+ supportFieldFormat: false,
+ layerId: state.layerId,
+ groupId: GROUP_ID.MIN,
+ groupLabel: i18n.translate('xpack.lens.gauge.minValueLabel', {
+ defaultMessage: 'Minimum value',
+ }),
+ accessors: state.minAccessor ? [{ columnId: state.minAccessor }] : [],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: !state.minAccessor,
+ dataTestSubj: 'lnsGauge_minDimensionPanel',
+ prioritizedOperation: 'min',
+ suggestedValue: () => (state.metricAccessor ? getMinValue(row, state) : undefined),
+ },
+ {
+ supportStaticValue: true,
+ supportFieldFormat: false,
+ layerId: state.layerId,
+ groupId: GROUP_ID.MAX,
+ groupLabel: i18n.translate('xpack.lens.gauge.maxValueLabel', {
+ defaultMessage: 'Maximum value',
+ }),
+ accessors: state.maxAccessor ? [{ columnId: state.maxAccessor }] : [],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: !state.maxAccessor,
+ dataTestSubj: 'lnsGauge_maxDimensionPanel',
+ prioritizedOperation: 'max',
+ suggestedValue: () => (state.metricAccessor ? getMaxValue(row, state) : undefined),
+ },
+ {
+ supportStaticValue: true,
+ supportFieldFormat: false,
+ layerId: state.layerId,
+ groupId: GROUP_ID.GOAL,
+ groupLabel: i18n.translate('xpack.lens.gauge.goalValueLabel', {
+ defaultMessage: 'Goal value',
+ }),
+ accessors: state.goalAccessor ? [{ columnId: state.goalAccessor }] : [],
+ filterOperations: isNumericMetric,
+ supportsMoreColumns: !state.goalAccessor,
+ required: false,
+ dataTestSubj: 'lnsGauge_goalDimensionPanel',
+ },
+ ],
+ };
+ },
+
+ setDimension({ prevState, layerId, columnId, groupId, previousColumn }) {
+ const update: Partial = {};
+ if (groupId === GROUP_ID.MIN) {
+ update.minAccessor = columnId;
+ }
+ if (groupId === GROUP_ID.MAX) {
+ update.maxAccessor = columnId;
+ }
+ if (groupId === GROUP_ID.GOAL) {
+ update.goalAccessor = columnId;
+ }
+ if (groupId === GROUP_ID.METRIC) {
+ update.metricAccessor = columnId;
+ }
+ return {
+ ...prevState,
+ ...update,
+ };
+ },
+
+ removeDimension({ prevState, layerId, columnId }) {
+ const update = { ...prevState };
+
+ if (prevState.goalAccessor === columnId) {
+ delete update.goalAccessor;
+ }
+ if (prevState.minAccessor === columnId) {
+ delete update.minAccessor;
+ }
+ if (prevState.maxAccessor === columnId) {
+ delete update.maxAccessor;
+ }
+ if (prevState.metricAccessor === columnId) {
+ delete update.metricAccessor;
+ delete update.palette;
+ delete update.colorMode;
+ update.ticksPosition = 'auto';
+ }
+
+ return update;
+ },
+
+ renderDimensionEditor(domElement, props) {
+ render(
+
+
+ ,
+ domElement
+ );
+ },
+
+ renderToolbar(domElement, props) {
+ render(
+
+
+ ,
+ domElement
+ );
+ },
+
+ getSupportedLayers(state, frame) {
+ const row = state?.layerId ? frame?.activeData?.[state?.layerId]?.rows?.[0] : undefined;
+
+ const minAccessorValue = getMinValue(row, state);
+ const maxAccessorValue = getMaxValue(row, state);
+ const goalAccessorValue = getGoalValue(row, state);
+
+ return [
+ {
+ type: layerTypes.DATA,
+ label: i18n.translate('xpack.lens.gauge.addLayer', {
+ defaultMessage: 'Add visualization layer',
+ }),
+ initialDimensions: state
+ ? [
+ {
+ groupId: 'min',
+ columnId: generateId(),
+ dataType: 'number',
+ label: 'minAccessor',
+ staticValue: minAccessorValue,
+ },
+ {
+ groupId: 'max',
+ columnId: generateId(),
+ dataType: 'number',
+ label: 'maxAccessor',
+ staticValue: maxAccessorValue,
+ },
+ {
+ groupId: 'goal',
+ columnId: generateId(),
+ dataType: 'number',
+ label: 'goalAccessor',
+ staticValue: goalAccessorValue,
+ },
+ ]
+ : undefined,
+ },
+ ];
+ },
+
+ getLayerType(layerId, state) {
+ if (state?.layerId === layerId) {
+ return state.layerType;
+ }
+ },
+
+ toExpression: (state, datasourceLayers, attributes) =>
+ toExpression(paletteService, state, datasourceLayers, { ...attributes }),
+ toPreviewExpression: (state, datasourceLayers) =>
+ toExpression(paletteService, state, datasourceLayers),
+
+ getErrorMessages(state) {
+ // not possible to break it?
+ return undefined;
+ },
+
+ getWarningMessages(state, frame) {
+ const { maxAccessor, minAccessor, goalAccessor, metricAccessor } = state;
+ if (!maxAccessor && !minAccessor && !goalAccessor && !metricAccessor) {
+ // nothing configured yet
+ return;
+ }
+ if (!metricAccessor) {
+ return [];
+ }
+
+ const row = frame?.activeData?.[state.layerId]?.rows?.[0];
+ if (!row) {
+ return [];
+ }
+ const metricValue = row[metricAccessor];
+ const maxValue = maxAccessor && row[maxAccessor];
+ const minValue = minAccessor && row[minAccessor];
+ const goalValue = goalAccessor && row[goalAccessor];
+
+ const warnings = [];
+ if (typeof minValue === 'number') {
+ if (minValue > metricValue) {
+ warnings.push([
+ ,
+ ]);
+ }
+ if (minValue > goalValue) {
+ warnings.push([
+ ,
+ ]);
+ }
+ }
+
+ if (typeof maxValue === 'number') {
+ if (metricValue > maxValue) {
+ warnings.push([
+ ,
+ ]);
+ }
+
+ if (typeof goalValue === 'number' && goalValue > maxValue) {
+ warnings.push([
+ ,
+ ]);
+ }
+ }
+
+ return warnings;
+ },
+});
diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
index bb3b5bfcbfec6..65425b04129d3 100644
--- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
@@ -45,7 +45,7 @@ import { shallow } from 'enzyme';
import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks';
import { mountWithIntl } from '@kbn/test/jest';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
-import { EmptyPlaceholder } from '../shared_components/empty_placeholder';
+import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import { XyEndzones } from './x_domain';
const onClickValue = jest.fn();
diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx
index d22a8034cdf2b..01359c68c6da3 100644
--- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx
@@ -44,6 +44,7 @@ import { IconType } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { RenderMode } from 'src/plugins/expressions';
import { ThemeServiceStart } from 'kibana/public';
+import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public';
import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types';
import type { LensMultiTable, FormatFactory } from '../../common';
@@ -61,7 +62,6 @@ import {
useActiveCursor,
} from '../../../../../src/plugins/charts/public';
import { MULTILAYER_TIME_AXIS_STYLE } from '../../../../../src/plugins/charts/common';
-import { EmptyPlaceholder } from '../shared_components';
import { getFitOptions } from './fitting_functions';
import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axes_configuration';
import { getColorAssignments } from './color_assignment';
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
index d536a18b6ab79..8330acf28264c 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
@@ -46,7 +46,9 @@ import { groupAxesByType } from './axes_configuration';
const defaultIcon = LensIconChartBarStacked;
const defaultSeriesType = 'bar_stacked';
+
const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number';
+const isNumericDynamicMetric = (op: OperationMetadata) => isNumericMetric(op) && !op.isStaticValue;
const isBucketed = (op: OperationMetadata) => op.isBucketed;
function getVisualizationType(state: State): VisualizationType | 'mixed' {
@@ -438,7 +440,7 @@ export const getXyVisualization = ({
groupId: 'y',
groupLabel: getAxisName('y', { isHorizontal }),
accessors: mappedAccessors,
- filterOperations: isNumericMetric,
+ filterOperations: isNumericDynamicMetric,
supportsMoreColumns: true,
required: true,
dataTestSubj: 'lnsXY_yDimensionPanel',
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts
index d7b48553ce73a..89714dff04a62 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts
@@ -38,6 +38,18 @@ describe('xy_suggestions', () => {
};
}
+ function staticValueCol(columnId: string): TableSuggestionColumn {
+ return {
+ columnId,
+ operation: {
+ dataType: 'number',
+ label: `Static value: ${columnId}`,
+ isBucketed: false,
+ isStaticValue: true,
+ },
+ };
+ }
+
function strCol(columnId: string): TableSuggestionColumn {
return {
columnId,
@@ -120,6 +132,21 @@ describe('xy_suggestions', () => {
);
});
+ test('rejects the configuration when metric isStaticValue', () => {
+ (generateId as jest.Mock).mockReturnValueOnce('aaa');
+ const suggestions = getSuggestions({
+ table: {
+ isMultiRow: true,
+ columns: [staticValueCol('value'), dateCol('date')],
+ layerId: 'first',
+ changeType: 'unchanged',
+ },
+ keptLayerIds: [],
+ });
+
+ expect(suggestions).toHaveLength(0);
+ });
+
test('rejects incomplete configurations if there is a state already but no sub visualization id', () => {
expect(
(
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
index 2e275c455a4d0..0fa07f4f9ebb6 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
@@ -69,7 +69,10 @@ export function getSuggestions({
});
}
- if (incompleteTable && state && !subVisualizationId) {
+ if (
+ (incompleteTable && state && !subVisualizationId) ||
+ table.columns.some((col) => col.operation.isStaticValue)
+ ) {
// reject incomplete configurations if the sub visualization isn't specifically requested
// this allows to switch chart types via switcher with incomplete configurations, but won't
// cause incomplete suggestions getting auto applied on dropped fields
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 07e680f336324..01bf1a6c2e149 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -14726,7 +14726,6 @@
"xpack.lens.xyVisualization.lineLabel": "折れ線",
"xpack.lens.xyVisualization.mixedBarHorizontalLabel": "混在した横棒",
"xpack.lens.xyVisualization.mixedLabel": "ミックスされた XY",
- "xpack.lens.xyVisualization.noDataLabel": "結果が見つかりませんでした",
"xpack.lens.xyVisualization.stackedAreaLabel": "面積み上げ",
"xpack.lens.xyVisualization.stackedBarHorizontalFullLabel": "積み上げ横棒",
"xpack.lens.xyVisualization.stackedBarHorizontalLabel": "H.積み上げ棒",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index d694eabe7f152..a1a772b10d463 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -14920,7 +14920,6 @@
"xpack.lens.xyVisualization.lineLabel": "折线图",
"xpack.lens.xyVisualization.mixedBarHorizontalLabel": "水平混合条形图",
"xpack.lens.xyVisualization.mixedLabel": "混合 XY",
- "xpack.lens.xyVisualization.noDataLabel": "找不到结果",
"xpack.lens.xyVisualization.stackedAreaLabel": "堆积面积图",
"xpack.lens.xyVisualization.stackedBarHorizontalFullLabel": "水平堆叠条形图",
"xpack.lens.xyVisualization.stackedBarHorizontalLabel": "水平堆叠条形图",
diff --git a/x-pack/test/functional/apps/lens/gauge.ts b/x-pack/test/functional/apps/lens/gauge.ts
new file mode 100644
index 0000000000000..fd81bad258280
--- /dev/null
+++ b/x-pack/test/functional/apps/lens/gauge.ts
@@ -0,0 +1,130 @@
+/*
+ * 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 elasticChart = getService('elasticChart');
+ const testSubjects = getService('testSubjects');
+ const find = getService('find');
+ const retry = getService('retry');
+
+ describe('lens gauge', () => {
+ before(async () => {
+ await PageObjects.visualize.navigateToNewVisualization();
+ await PageObjects.visualize.clickVisType('lens');
+ await elasticChart.setNewChartUiDebugFlag(true);
+ await PageObjects.lens.goToTimeRange();
+
+ await PageObjects.lens.configureDimension({
+ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
+ operation: 'terms',
+ field: 'ip',
+ });
+
+ await PageObjects.lens.configureDimension({
+ dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
+ operation: 'average',
+ field: 'bytes',
+ });
+
+ await PageObjects.lens.waitForVisualization();
+ });
+
+ it('should switch to gauge and render a gauge with default values', async () => {
+ await PageObjects.lens.switchToVisualization('horizontalBullet', 'gauge');
+ await PageObjects.lens.waitForVisualization();
+ const elementWithInfo = await find.byCssSelector('.echScreenReaderOnly');
+ const textContent = await elementWithInfo.getAttribute('textContent');
+ expect(textContent).to.contain('Average of bytes'); // it gets default title
+ expect(textContent).to.contain('horizontalBullet chart');
+ expect(textContent).to.contain('Minimum:0'); // it gets default minimum static value
+ expect(textContent).to.contain('Maximum:8000'); // it gets default maximum static value
+ expect(textContent).to.contain('Value:5727.32');
+ });
+
+ it('should reflect edits for gauge', async () => {
+ await PageObjects.lens.openVisualOptions();
+ await retry.try(async () => {
+ await testSubjects.setValue('lnsToolbarGaugeLabelMajor', 'custom title');
+ });
+ await retry.try(async () => {
+ await testSubjects.setValue('lnsToolbarGaugeLabelMinor-select', 'custom');
+ });
+ await retry.try(async () => {
+ await testSubjects.setValue('lnsToolbarGaugeLabelMinor', 'custom subtitle');
+ });
+
+ await PageObjects.lens.waitForVisualization();
+ await PageObjects.lens.configureDimension({
+ dimension: 'lnsGauge_metricDimensionPanel > lns-dimensionTrigger',
+ operation: 'count',
+ isPreviousIncompatible: true,
+ keepOpen: true,
+ });
+
+ await testSubjects.setEuiSwitch('lnsDynamicColoringGaugeSwitch', 'check');
+ await PageObjects.lens.closeDimensionEditor();
+
+ await PageObjects.lens.openDimensionEditor(
+ 'lnsGauge_goalDimensionPanel > lns-empty-dimension'
+ );
+
+ await PageObjects.lens.waitForVisualization();
+ await PageObjects.lens.closeDimensionEditor();
+ await PageObjects.lens.openDimensionEditor(
+ 'lnsGauge_minDimensionPanel > lns-empty-dimension-suggested-value'
+ );
+
+ await testSubjects.setValue('lns-indexPattern-static_value-input', '1000', {
+ clearWithKeyboard: true,
+ });
+ await PageObjects.lens.waitForVisualization();
+ await PageObjects.lens.closeDimensionEditor();
+
+ await PageObjects.lens.openDimensionEditor(
+ 'lnsGauge_maxDimensionPanel > lns-empty-dimension-suggested-value'
+ );
+
+ await testSubjects.setValue('lns-indexPattern-static_value-input', '25000', {
+ clearWithKeyboard: true,
+ });
+ await PageObjects.lens.waitForVisualization();
+ await PageObjects.lens.closeDimensionEditor();
+
+ const elementWithInfo = await find.byCssSelector('.echScreenReaderOnly');
+ const textContent = await elementWithInfo.getAttribute('textContent');
+ expect(textContent).to.contain('custom title');
+ expect(textContent).to.contain('custom subtitle');
+ expect(textContent).to.contain('horizontalBullet chart');
+ expect(textContent).to.contain('Minimum:1000');
+ expect(textContent).to.contain('Maximum:25000');
+ expect(textContent).to.contain('Target:15000');
+ expect(textContent).to.contain('Value:14005');
+ });
+ it('should seamlessly switch to vertical chart without losing configuration', async () => {
+ await PageObjects.lens.switchToVisualization('verticalBullet', 'gauge');
+ const elementWithInfo = await find.byCssSelector('.echScreenReaderOnly');
+ const textContent = await elementWithInfo.getAttribute('textContent');
+ expect(textContent).to.contain('custom title');
+ expect(textContent).to.contain('custom subtitle');
+ expect(textContent).to.contain('verticalBullet chart');
+ expect(textContent).to.contain('Minimum:1000');
+ expect(textContent).to.contain('Maximum:25000');
+ expect(textContent).to.contain('Target:15000');
+ expect(textContent).to.contain('Value:14005');
+ });
+ it('should switch to table chart and filter not supported static values', async () => {
+ await PageObjects.lens.switchToVisualization('lnsDatatable');
+ const columnsCount = await PageObjects.lens.getCountOfDatatableColumns();
+ expect(columnsCount).to.eql(1);
+ expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal('Count of records');
+ });
+ });
+}
diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts
index 24bb1440af622..8d1363b30c43f 100644
--- a/x-pack/test/functional/apps/lens/index.ts
+++ b/x-pack/test/functional/apps/lens/index.ts
@@ -67,6 +67,7 @@ export default function ({ getService, loadTestFile, getPageObjects }: FtrProvid
loadTestFile(require.resolve('./geo_field'));
loadTestFile(require.resolve('./formula'));
loadTestFile(require.resolve('./heatmap'));
+ loadTestFile(require.resolve('./gauge'));
loadTestFile(require.resolve('./metrics'));
loadTestFile(require.resolve('./reference_lines'));
loadTestFile(require.resolve('./inspector'));
diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts
index 78b9762e3889a..77b420804737e 100644
--- a/x-pack/test/functional/page_objects/lens_page.ts
+++ b/x-pack/test/functional/page_objects/lens_page.ts
@@ -807,6 +807,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
}, {});
},
+ async getCountOfDatatableColumns() {
+ const table = await find.byCssSelector('.euiDataGrid');
+ const $ = await table.parseDomContent();
+ return (await $('.euiDataGridHeaderCell__content')).length;
+ },
+
async getDatatableHeader(index = 0) {
log.debug(`All headers ${await testSubjects.getVisibleText('dataGridHeader')}`);
return find.byCssSelector(
@@ -817,9 +823,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
},
async getDatatableCell(rowIndex = 0, colIndex = 0) {
- const table = await find.byCssSelector('.euiDataGrid');
- const $ = await table.parseDomContent();
- const columnNumber = $('.euiDataGridHeaderCell__content').length;
+ const columnNumber = await this.getCountOfDatatableColumns();
return await find.byCssSelector(
`[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRowCell"]:nth-child(${
rowIndex * columnNumber + colIndex + 2