From f9e7cb0bc0b80f3d71427b1ad1eebc58e5bea101 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Wed, 2 Dec 2020 19:55:58 +0100 Subject: [PATCH] [Lens] Provide single-value functions to show the "First" or "Last" value of some field (#83437) --- .../workspace_panel/warnings_popover.scss | 17 + .../workspace_panel/warnings_popover.tsx | 57 +++ .../workspace_panel_wrapper.tsx | 66 ++- .../dimension_panel/dimension_editor.scss | 4 + .../dimension_panel/dimension_editor.tsx | 28 +- .../dimension_panel/dimension_panel.test.tsx | 2 +- .../dimension_panel/dimension_panel.tsx | 18 +- .../dimension_panel/droppable.test.ts | 2 - .../indexpattern.test.ts | 12 +- .../indexpattern_datasource/indexpattern.tsx | 41 +- .../indexpattern_suggestions.ts | 6 +- .../indexpattern_datasource/loader.test.ts | 2 - .../operations/__mocks__/index.ts | 1 + .../operations/definitions/cardinality.tsx | 4 + .../operations/definitions/count.tsx | 3 + .../operations/definitions/date_histogram.tsx | 3 + .../operations/definitions/helpers.tsx | 34 ++ .../operations/definitions/index.ts | 40 +- .../definitions/last_value.test.tsx | 477 ++++++++++++++++++ .../operations/definitions/last_value.tsx | 257 ++++++++++ .../operations/definitions/metrics.tsx | 3 + .../operations/definitions/ranges/ranges.tsx | 3 + .../operations/definitions/terms/index.tsx | 5 +- .../definitions/terms/terms.test.tsx | 83 ++- .../operations/layer_helpers.test.ts | 1 - .../operations/mocks.ts | 1 + .../operations/operations.test.ts | 19 + .../public/indexpattern_datasource/utils.ts | 64 +-- .../pie_visualization/visualization.tsx | 28 + x-pack/plugins/lens/public/types.ts | 5 + .../xy_visualization/visualization.test.ts | 65 +++ .../public/xy_visualization/visualization.tsx | 32 ++ 32 files changed, 1256 insertions(+), 127 deletions(-) create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/warnings_popover.scss create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/warnings_popover.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/warnings_popover.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/warnings_popover.scss new file mode 100644 index 0000000000000..19f815dfb7114 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/warnings_popover.scss @@ -0,0 +1,17 @@ +.lnsWorkspaceWarning__button { + color: $euiColorWarningText; +} + +.lnsWorkspaceWarningList { + @include euiYScroll; + max-height: $euiSize * 20; + width: $euiSize * 16; +} + +.lnsWorkspaceWarningList__item { + padding: $euiSize; + + & + & { + border-top: $euiBorderThin; + } +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/warnings_popover.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/warnings_popover.tsx new file mode 100644 index 0000000000000..cb414972e84af --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/warnings_popover.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './workspace_panel_wrapper.scss'; +import './warnings_popover.scss'; + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiPopover, EuiText, EuiButtonEmpty } from '@elastic/eui'; + +export const WarningsPopover = ({ + children, +}: { + children?: React.ReactNode | React.ReactNode[]; +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + if (!children) { + return null; + } + + const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const closePopover = () => setIsPopoverOpen(false); + const warningsCount = React.Children.count(children); + return ( + + {i18n.translate('xpack.lens.chartWarnings.number', { + defaultMessage: `{warningsCount} {warningsCount, plural, one {warning} other {warnings}}`, + values: { + warningsCount, + }, + })} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + > + + + ); +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index d9fbaa22a0388..046bebb33a57d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -19,6 +19,7 @@ import { Datasource, FramePublicAPI, Visualization } from '../../../types'; import { NativeRenderer } from '../../../native_renderer'; import { Action } from '../state_management'; import { ChartSwitch } from './chart_switch'; +import { WarningsPopover } from './warnings_popover'; export interface WorkspacePanelWrapperProps { children: React.ReactNode | React.ReactNode[]; @@ -64,40 +65,59 @@ export function WorkspacePanelWrapper({ }, [dispatch, activeVisualization] ); + const warningMessages = + activeVisualization?.getWarningMessages && + activeVisualization.getWarningMessages(visualizationState, framePublicAPI); return ( <>
- + + + + + {activeVisualization && activeVisualization.renderToolbar && ( + + + + )} + + + + {warningMessages && warningMessages.length ? ( + {warningMessages} + ) : null} - {activeVisualization && activeVisualization.renderToolbar && ( - - - - )}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.scss b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.scss index 096047da681b9..6bd6808f17b35 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.scss @@ -15,6 +15,10 @@ column-gap: $euiSizeXL; } +.lnsIndexPatternDimensionEditor__operation .euiListGroupItem__label { + width: 100%; +} + .lnsIndexPatternDimensionEditor__operation > button { padding-top: 0; padding-bottom: 0; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 4c3def0e5bc7f..576825e9c960a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -15,6 +15,7 @@ import { EuiSpacer, EuiListGroupItemProps, EuiFormLabel, + EuiToolTip, } from '@elastic/eui'; import { IndexPatternDimensionEditorProps } from './dimension_panel'; import { OperationSupportMatrix } from './operation_support'; @@ -141,20 +142,19 @@ export function DimensionEditor(props: DimensionEditorProps) { definition.input === 'field' && fieldByOperation[operationType]?.has(selectedColumn.sourceField)) || (selectedColumn && !hasField(selectedColumn) && definition.input === 'none'), + disabledStatus: + definition.getDisabledStatus && + definition.getDisabledStatus(state.indexPatterns[state.currentIndexPatternId]), }; }); - const selectedColumnSourceField = - selectedColumn && 'sourceField' in selectedColumn ? selectedColumn.sourceField : undefined; - - const currentFieldIsInvalid = useMemo( - () => - fieldIsInvalid(selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern), - [selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern] - ); + const currentFieldIsInvalid = useMemo(() => fieldIsInvalid(selectedColumn, currentIndexPattern), [ + selectedColumn, + currentIndexPattern, + ]); const sideNavItems: EuiListGroupItemProps[] = operationsWithCompatibility.map( - ({ operationType, compatibleWithCurrentField }) => { + ({ operationType, compatibleWithCurrentField, disabledStatus }) => { const isActive = Boolean( incompleteOperation === operationType || (!incompleteOperation && selectedColumn && selectedColumn.operationType === operationType) @@ -168,7 +168,13 @@ export function DimensionEditor(props: DimensionEditorProps) { } let label: EuiListGroupItemProps['label'] = operationPanels[operationType].displayName; - if (isActive) { + if (disabledStatus) { + label = ( + + {operationPanels[operationType].displayName} + + ); + } else if (isActive) { label = {operationPanels[operationType].displayName}; } @@ -178,6 +184,7 @@ export function DimensionEditor(props: DimensionEditorProps) { color, isActive, size: 's', + isDisabled: !!disabledStatus, className: 'lnsIndexPatternDimensionEditor__operation', 'data-test-subj': `lns-indexPatternDimension-${operationType}${ compatibleWithCurrentField ? '' : ' incompatible' @@ -264,7 +271,6 @@ export function DimensionEditor(props: DimensionEditorProps) { ? currentIndexPattern.getFieldByName(selectedColumn.sourceField) : undefined, }); - setState(mergeLayer({ state, layerId, newLayer })); }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index d4197f9395660..dbfffb5c2bd59 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -1242,12 +1242,12 @@ describe('IndexPatternDimensionEditorPanel', () => { expect(items.map(({ label }: { label: React.ReactNode }) => label)).toEqual([ 'Average', 'Count', + 'Last value', 'Maximum', 'Median', 'Minimum', 'Sum', 'Unique count', - '\u00a0', ]); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index 2444a6a81c2a0..20134699d2067 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -12,7 +12,7 @@ import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { DatasourceDimensionTriggerProps, DatasourceDimensionEditorProps } from '../../types'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternColumn } from '../indexpattern'; -import { fieldIsInvalid } from '../utils'; +import { isColumnInvalid } from '../utils'; import { IndexPatternPrivateState } from '../types'; import { DimensionEditor } from './dimension_editor'; import { DateRange } from '../../../common'; @@ -45,24 +45,22 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens ) { const layerId = props.layerId; const layer = props.state.layers[layerId]; - const selectedColumn: IndexPatternColumn | null = layer.columns[props.columnId] || null; const currentIndexPattern = props.state.indexPatterns[layer.indexPatternId]; + const { columnId, uniqueLabel } = props; - const selectedColumnSourceField = - selectedColumn && 'sourceField' in selectedColumn ? selectedColumn.sourceField : undefined; - const currentFieldIsInvalid = useMemo( - () => - fieldIsInvalid(selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern), - [selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern] + const currentColumnHasErrors = useMemo( + () => isColumnInvalid(layer, columnId, currentIndexPattern), + [layer, columnId, currentIndexPattern] ); - const { columnId, uniqueLabel } = props; + const selectedColumn: IndexPatternColumn | null = layer.columns[props.columnId] || null; + if (!selectedColumn) { return null; } const formattedLabel = wrapOnDot(uniqueLabel); - if (currentFieldIsInvalid) { + if (currentColumnHasErrors) { return ( { expect(messages).toHaveLength(1); expect(messages![0]).toEqual({ shortMessage: 'Invalid reference.', - longMessage: 'Field "bytes" has an invalid reference.', + longMessage: '"Foo" has an invalid reference.', }); }); @@ -844,7 +844,7 @@ describe('IndexPattern Data Source', () => { col2: { dataType: 'number', isBucketed: false, - label: 'Foo', + label: 'Foo2', operationType: 'count', // <= invalid sourceField: 'memory', }, @@ -857,7 +857,7 @@ describe('IndexPattern Data Source', () => { expect(messages).toHaveLength(1); expect(messages![0]).toEqual({ shortMessage: 'Invalid references.', - longMessage: 'Fields "bytes", "memory" have invalid reference.', + longMessage: '"Foo", "Foo2" have invalid reference.', }); }); @@ -882,7 +882,7 @@ describe('IndexPattern Data Source', () => { col2: { dataType: 'number', isBucketed: false, - label: 'Foo', + label: 'Foo2', operationType: 'count', // <= invalid sourceField: 'memory', }, @@ -909,11 +909,11 @@ describe('IndexPattern Data Source', () => { expect(messages).toEqual([ { shortMessage: 'Invalid references on Layer 1.', - longMessage: 'Layer 1 has invalid references in fields "bytes", "memory".', + longMessage: 'Layer 1 has invalid references in "Foo", "Foo2".', }, { shortMessage: 'Invalid reference on Layer 2.', - longMessage: 'Layer 2 has an invalid reference in field "source".', + longMessage: 'Layer 2 has an invalid reference in "Foo".', }, ]); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 948619c6b07e5..a639ea2c00ac0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -40,7 +40,7 @@ import { } from './indexpattern_suggestions'; import { - getInvalidFieldsForLayer, + getInvalidColumnsForLayer, getInvalidLayers, isDraggedField, normalizeOperationDataType, @@ -387,7 +387,7 @@ export function getIndexPatternDatasource({ } }) .filter(Boolean) as Array<[number, number]>; - const invalidFieldsPerLayer: string[][] = getInvalidFieldsForLayer( + const invalidColumnsForLayer: string[][] = getInvalidColumnsForLayer( invalidLayers, state.indexPatterns ); @@ -397,33 +397,34 @@ export function getIndexPatternDatasource({ return [ ...layerErrors, ...realIndex.map(([filteredIndex, layerIndex]) => { - const fieldsWithBrokenReferences: string[] = invalidFieldsPerLayer[filteredIndex].map( - (columnId) => { - const column = invalidLayers[filteredIndex].columns[ - columnId - ] as FieldBasedIndexPatternColumn; - return column.sourceField; - } - ); + const columnLabelsWithBrokenReferences: string[] = invalidColumnsForLayer[ + filteredIndex + ].map((columnId) => { + const column = invalidLayers[filteredIndex].columns[ + columnId + ] as FieldBasedIndexPatternColumn; + return column.label; + }); if (originalLayersList.length === 1) { return { shortMessage: i18n.translate( 'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer', { - defaultMessage: 'Invalid {fields, plural, one {reference} other {references}}.', + defaultMessage: + 'Invalid {columns, plural, one {reference} other {references}}.', values: { - fields: fieldsWithBrokenReferences.length, + columns: columnLabelsWithBrokenReferences.length, }, } ), longMessage: i18n.translate( 'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer', { - defaultMessage: `{fieldsLength, plural, one {Field} other {Fields}} "{fields}" {fieldsLength, plural, one {has an} other {have}} invalid reference.`, + defaultMessage: `"{columns}" {columnsLength, plural, one {has an} other {have}} invalid reference.`, values: { - fields: fieldsWithBrokenReferences.join('", "'), - fieldsLength: fieldsWithBrokenReferences.length, + columns: columnLabelsWithBrokenReferences.join('", "'), + columnsLength: columnLabelsWithBrokenReferences.length, }, } ), @@ -432,18 +433,18 @@ export function getIndexPatternDatasource({ return { shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', { defaultMessage: - 'Invalid {fieldsLength, plural, one {reference} other {references}} on Layer {layer}.', + 'Invalid {columnsLength, plural, one {reference} other {references}} on Layer {layer}.', values: { layer: layerIndex, - fieldsLength: fieldsWithBrokenReferences.length, + columnsLength: columnLabelsWithBrokenReferences.length, }, }), longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', { - defaultMessage: `Layer {layer} has {fieldsLength, plural, one {an invalid} other {invalid}} {fieldsLength, plural, one {reference} other {references}} in {fieldsLength, plural, one {field} other {fields}} "{fields}".`, + defaultMessage: `Layer {layer} has {columnsLength, plural, one {an invalid} other {invalid}} {columnsLength, plural, one {reference} other {references}} in "{columns}".`, values: { layer: layerIndex, - fields: fieldsWithBrokenReferences.join('", "'), - fieldsLength: fieldsWithBrokenReferences.length, + columns: columnLabelsWithBrokenReferences.join('", "'), + columnsLength: columnLabelsWithBrokenReferences.length, }, }), }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 263b4646c9feb..ebac396210a5c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -18,7 +18,7 @@ import { IndexPatternColumn, OperationType, } from './operations'; -import { hasField, hasInvalidFields } from './utils'; +import { hasField, hasInvalidColumns } from './utils'; import { IndexPattern, IndexPatternPrivateState, @@ -90,7 +90,7 @@ export function getDatasourceSuggestionsForField( indexPatternId: string, field: IndexPatternField ): IndexPatternSugestion[] { - if (hasInvalidFields(state)) return []; + if (hasInvalidColumns(state)) return []; const layers = Object.keys(state.layers); const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId); @@ -331,7 +331,7 @@ function createNewLayerWithMetricAggregation( export function getDatasourceSuggestionsFromCurrentState( state: IndexPatternPrivateState ): Array> { - if (hasInvalidFields(state)) return []; + if (hasInvalidColumns(state)) return []; const layers = Object.entries(state.layers || {}); if (layers.length > 1) { // Return suggestions that reduce the data to each layer individually diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index adb86253ab28c..29786d9bc68f3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -25,8 +25,6 @@ import { import { createMockedRestrictedIndexPattern, createMockedIndexPattern } from './mocks'; import { documentField } from './document_field'; -jest.mock('./operations'); - const createMockStorage = (lastData?: Record) => { return { get: jest.fn().mockImplementation(() => lastData), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts index 077255b75a769..ff900134df9a1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts @@ -24,6 +24,7 @@ export const { getOperationResultType, operationDefinitionMap, operationDefinitions, + getInvalidFieldMessage, } = actualOperations; export const { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index fd3ca4319669e..2dc3946c62a09 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -8,6 +8,8 @@ import { i18n } from '@kbn/i18n'; import { OperationDefinition } from './index'; import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types'; +import { getInvalidFieldMessage } from './helpers'; + const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']); const SCALE = 'ratio'; @@ -42,6 +44,8 @@ export const cardinalityOperation: OperationDefinition + getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), isTransferable: (column, newIndexPattern) => { const newField = newIndexPattern.getFieldByName(column.sourceField); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx index 8cb95de72f97e..02a69ad8e550f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { OperationDefinition } from './index'; import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types'; import { IndexPatternField } from '../../types'; +import { getInvalidFieldMessage } from './helpers'; import { adjustTimeScaleLabelSuffix, adjustTimeScaleOnOtherColumnChange, @@ -29,6 +30,8 @@ export const countOperation: OperationDefinition + getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), onFieldChange: (oldColumn, field) => { return { ...oldColumn, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index efac9c151a435..ca426fb53a3ab 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -23,6 +23,7 @@ import { updateColumnParam } from '../layer_helpers'; import { OperationDefinition } from './index'; import { FieldBasedIndexPatternColumn } from './column_types'; import { IndexPatternAggRestrictions, search } from '../../../../../../../src/plugins/data/public'; +import { getInvalidFieldMessage } from './helpers'; const { isValidInterval } = search.aggs; const autoInterval = 'auto'; @@ -46,6 +47,8 @@ export const dateHistogramOperation: OperationDefinition< }), input: 'field', priority: 5, // Highest priority level used + getErrorMessage: (layer, columnId, indexPattern) => + getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( type === 'date' && diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx index a5c08a93467af..640a357d9a7a4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx @@ -6,6 +6,10 @@ import { useRef } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; +import { i18n } from '@kbn/i18n'; +import { operationDefinitionMap } from '.'; +import { FieldBasedIndexPatternColumn } from './column_types'; +import { IndexPattern } from '../../types'; export const useDebounceWithOptions = ( fn: Function, @@ -28,3 +32,33 @@ export const useDebounceWithOptions = ( newDeps ); }; + +export function getInvalidFieldMessage( + column: FieldBasedIndexPatternColumn, + indexPattern?: IndexPattern +) { + if (!indexPattern) { + return; + } + const { sourceField, operationType } = column; + const field = sourceField ? indexPattern.getFieldByName(sourceField) : undefined; + const operationDefinition = operationType && operationDefinitionMap[operationType]; + + const isInvalid = Boolean( + sourceField && + operationDefinition && + !( + field && + operationDefinition?.input === 'field' && + operationDefinition.getPossibleOperationForField(field) !== undefined + ) + ); + return isInvalid + ? [ + i18n.translate('xpack.lens.indexPattern.fieldNotFound', { + defaultMessage: 'Field {invalidField} was not found', + values: { invalidField: sourceField }, + }), + ] + : undefined; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 31bb332f791da..460c7c5492879 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -34,6 +34,7 @@ import { MovingAverageIndexPatternColumn, } from './calculations'; import { countOperation, CountIndexPatternColumn } from './count'; +import { lastValueOperation, LastValueIndexPatternColumn } from './last_value'; import { StateSetter, OperationMetadata } from '../../../types'; import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; import { @@ -63,6 +64,7 @@ export type IndexPatternColumn = | SumIndexPatternColumn | MedianIndexPatternColumn | CountIndexPatternColumn + | LastValueIndexPatternColumn | CumulativeSumIndexPatternColumn | CounterRateIndexPatternColumn | DerivativeIndexPatternColumn @@ -85,6 +87,7 @@ const internalOperationDefinitions = [ cardinalityOperation, sumOperation, medianOperation, + lastValueOperation, countOperation, rangeOperation, cumulativeSumOperation, @@ -99,6 +102,7 @@ export { filtersOperation } from './filters'; export { dateHistogramOperation } from './date_histogram'; export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; export { countOperation } from './count'; +export { lastValueOperation } from './last_value'; export { cumulativeSumOperation, counterRateOperation, @@ -173,6 +177,24 @@ interface BaseOperationDefinitionProps { */ transfer?: (column: C, newIndexPattern: IndexPattern) => C; /** + * if there is some reason to display the operation in the operations list + * but disable it from usage, this function returns the string describing + * the status. Otherwise it returns undefined + */ + getDisabledStatus?: (indexPattern: IndexPattern) => string | undefined; + /** + * Validate that the operation has the right preconditions in the state. For example: + * + * - Requires a date histogram operation somewhere before it in order + * - Missing references + */ + getErrorMessage?: ( + layer: IndexPatternLayer, + columnId: string, + indexPattern?: IndexPattern + ) => string[] | undefined; + + /* * Flag whether this operation can be scaled by time unit if a date histogram is available. * If set to mandatory or optional, a UI element is shown in the config flyout to configure the time unit * to scale by. The chosen unit will be persisted as `timeScale` property of the column. @@ -245,6 +267,17 @@ interface FieldBasedOperationDefinition { * together with the agg configs returned from other columns. */ toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown; + /** + * Validate that the operation has the right preconditions in the state. For example: + * + * - Requires a date histogram operation somewhere before it in order + * - Missing references + */ + getErrorMessage: ( + layer: IndexPatternLayer, + columnId: string, + indexPattern?: IndexPattern + ) => string[] | undefined; } export interface RequiredReference { @@ -297,13 +330,6 @@ interface FullReferenceOperationDefinition { columnId: string, indexPattern: IndexPattern ) => ExpressionFunctionAST[]; - /** - * Validate that the operation has the right preconditions in the state. For example: - * - * - Requires a date histogram operation somewhere before it in order - * - Missing references - */ - getErrorMessage?: (layer: IndexPatternLayer, columnId: string) => string[] | undefined; } interface OperationDefinitionMap { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx new file mode 100644 index 0000000000000..09b68e78d3469 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx @@ -0,0 +1,477 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiComboBox } from '@elastic/eui'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { createMockedIndexPattern } from '../../mocks'; +import { LastValueIndexPatternColumn } from './last_value'; +import { lastValueOperation } from './index'; +import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from '../../types'; + +const defaultProps = { + storage: {} as IStorageWrapper, + uiSettings: {} as IUiSettingsClient, + savedObjectsClient: {} as SavedObjectsClientContract, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + data: dataPluginMock.createStartContract(), + http: {} as HttpSetup, +}; + +describe('last_value', () => { + let state: IndexPatternPrivateState; + const InlineOptions = lastValueOperation.paramEditor!; + + beforeEach(() => { + const indexPattern = createMockedIndexPattern(); + state = { + indexPatternRefs: [], + indexPatterns: { + '1': { + ...indexPattern, + hasRestrictions: false, + } as IndexPattern, + }, + existingFields: {}, + currentIndexPatternId: '1', + isFirstExistenceFetch: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }, + col2: { + label: 'Last value of a', + dataType: 'number', + isBucketed: false, + sourceField: 'a', + operationType: 'last_value', + params: { + sortField: 'datefield', + }, + }, + }, + }, + }, + }; + }); + + describe('toEsAggsConfig', () => { + it('should reflect params correctly', () => { + const lastValueColumn = state.layers.first.columns.col2 as LastValueIndexPatternColumn; + const esAggsConfig = lastValueOperation.toEsAggsConfig( + { ...lastValueColumn, params: { ...lastValueColumn.params } }, + 'col1', + {} as IndexPattern + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + aggregate: 'concat', + field: 'a', + size: 1, + sortField: 'datefield', + sortOrder: 'desc', + }), + }) + ); + }); + }); + + describe('onFieldChange', () => { + it('should change correctly to new field', () => { + const oldColumn: LastValueIndexPatternColumn = { + operationType: 'last_value', + sourceField: 'source', + label: 'Last value of source', + isBucketed: true, + dataType: 'string', + params: { + sortField: 'datefield', + }, + }; + const indexPattern = createMockedIndexPattern(); + const newNumberField = indexPattern.getFieldByName('bytes')!; + const column = lastValueOperation.onFieldChange(oldColumn, newNumberField); + + expect(column).toEqual( + expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + params: expect.objectContaining({ + sortField: 'datefield', + }), + }) + ); + expect(column.label).toContain('bytes'); + }); + + it('should remove numeric parameters when changing away from number', () => { + const oldColumn: LastValueIndexPatternColumn = { + operationType: 'last_value', + sourceField: 'bytes', + label: 'Last value of bytes', + isBucketed: false, + dataType: 'number', + params: { + sortField: 'datefield', + }, + }; + const indexPattern = createMockedIndexPattern(); + const newStringField = indexPattern.fields.find((i) => i.name === 'source')!; + + const column = lastValueOperation.onFieldChange(oldColumn, newStringField); + expect(column).toHaveProperty('dataType', 'string'); + expect(column).toHaveProperty('sourceField', 'source'); + expect(column.params.format).toBeUndefined(); + }); + }); + + describe('getPossibleOperationForField', () => { + it('should return operation with the right type', () => { + expect( + lastValueOperation.getPossibleOperationForField({ + aggregatable: true, + searchable: true, + name: 'test', + displayName: 'test', + type: 'boolean', + }) + ).toEqual({ + dataType: 'boolean', + isBucketed: false, + scale: 'ratio', + }); + + expect( + lastValueOperation.getPossibleOperationForField({ + aggregatable: true, + searchable: true, + name: 'test', + displayName: 'test', + type: 'ip', + }) + ).toEqual({ + dataType: 'ip', + isBucketed: false, + scale: 'ratio', + }); + }); + + it('should not return an operation if restrictions prevent terms', () => { + expect( + lastValueOperation.getPossibleOperationForField({ + aggregatable: true, + searchable: true, + name: 'test', + displayName: 'test', + type: 'string', + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }) + ).toEqual(undefined); + + expect( + lastValueOperation.getPossibleOperationForField({ + aggregatable: true, + aggregationRestrictions: {}, + searchable: true, + name: 'test', + displayName: 'test', + type: 'string', + }) + ).toEqual(undefined); + // does it have to be aggregatable? + expect( + lastValueOperation.getPossibleOperationForField({ + aggregatable: false, + searchable: true, + name: 'test', + displayName: 'test', + type: 'string', + }) + ).toEqual({ dataType: 'string', isBucketed: false, scale: 'ordinal' }); + }); + }); + + describe('buildColumn', () => { + it('should use type from the passed field', () => { + const lastValueColumn = lastValueOperation.buildColumn({ + indexPattern: createMockedIndexPattern(), + field: { + aggregatable: true, + searchable: true, + type: 'boolean', + name: 'test', + displayName: 'test', + }, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + }); + expect(lastValueColumn.dataType).toEqual('boolean'); + }); + + it('should use indexPattern timeFieldName as a default sortField', () => { + const lastValueColumn = lastValueOperation.buildColumn({ + indexPattern: createMockedIndexPattern(), + + layer: { + columns: { + col1: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + columnOrder: [], + indexPatternId: '', + }, + + field: { + aggregatable: true, + searchable: true, + type: 'boolean', + name: 'test', + displayName: 'test', + }, + }); + expect(lastValueColumn.params).toEqual( + expect.objectContaining({ + sortField: 'timestamp', + }) + ); + }); + + it('should use first indexPattern date field if there is no default timefieldName', () => { + const indexPattern = createMockedIndexPattern(); + const indexPatternNoTimeField = { + ...indexPattern, + timeFieldName: undefined, + fields: [ + { + aggregatable: true, + searchable: true, + type: 'date', + name: 'datefield', + displayName: 'datefield', + }, + { + aggregatable: true, + searchable: true, + type: 'boolean', + name: 'test', + displayName: 'test', + }, + ], + }; + const lastValueColumn = lastValueOperation.buildColumn({ + indexPattern: indexPatternNoTimeField, + + layer: { + columns: { + col1: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + columnOrder: [], + indexPatternId: '', + }, + + field: { + aggregatable: true, + searchable: true, + type: 'boolean', + name: 'test', + displayName: 'test', + }, + }); + expect(lastValueColumn.params).toEqual( + expect.objectContaining({ + sortField: 'datefield', + }) + ); + }); + }); + + it('should return disabledStatus if indexPattern does contain date field', () => { + const indexPattern = createMockedIndexPattern(); + + expect(lastValueOperation.getDisabledStatus!(indexPattern)).toEqual(undefined); + + const indexPatternWithoutTimeFieldName = { + ...indexPattern, + timeFieldName: undefined, + }; + expect(lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName)).toEqual( + undefined + ); + + const indexPatternWithoutTimefields = { + ...indexPatternWithoutTimeFieldName, + fields: indexPattern.fields.filter((f) => f.type !== 'date'), + }; + + const disabledStatus = lastValueOperation.getDisabledStatus!(indexPatternWithoutTimefields); + expect(disabledStatus).toEqual( + 'This function requires the presence of a date field in your index' + ); + }); + + describe('param editor', () => { + it('should render current sortField', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + const select = instance.find('[data-test-subj="lns-indexPattern-lastValue-sortField"]'); + + expect(select.prop('selectedOptions')).toEqual([{ label: 'datefield', value: 'datefield' }]); + }); + + it('should update state when changing sortField', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + instance + .find('[data-test-subj="lns-indexPattern-lastValue-sortField"]') + .find(EuiComboBox) + .prop('onChange')!([{ label: 'datefield2', value: 'datefield2' }]); + + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + ...state.layers.first.columns.col2, + params: { + ...(state.layers.first.columns.col2 as LastValueIndexPatternColumn).params, + sortField: 'datefield2', + }, + }, + }, + }, + }, + }); + }); + }); + + describe('getErrorMessage', () => { + let indexPattern: IndexPattern; + let layer: IndexPatternLayer; + beforeEach(() => { + indexPattern = createMockedIndexPattern(); + layer = { + columns: { + col1: { + dataType: 'boolean', + isBucketed: false, + label: 'Last value of test', + operationType: 'last_value', + params: { sortField: 'timestamp' }, + scale: 'ratio', + sourceField: 'bytes', + }, + }, + columnOrder: [], + indexPatternId: '', + }; + }); + it('returns undefined if sourceField exists and sortField is of type date ', () => { + expect(lastValueOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual(undefined); + }); + it('shows error message if the sourceField does not exist in index pattern', () => { + layer = { + ...layer, + columns: { + col1: { + ...layer.columns.col1, + sourceField: 'notExisting', + } as LastValueIndexPatternColumn, + }, + }; + expect(lastValueOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([ + 'Field notExisting was not found', + ]); + }); + it('shows error message if the sortField does not exist in index pattern', () => { + layer = { + ...layer, + columns: { + col1: { + ...layer.columns.col1, + params: { + ...layer.columns.col1.params, + sortField: 'notExisting', + }, + } as LastValueIndexPatternColumn, + }, + }; + expect(lastValueOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([ + 'Field notExisting was not found', + ]); + }); + it('shows error message if the sortField is not date', () => { + layer = { + ...layer, + columns: { + col1: { + ...layer.columns.col1, + params: { + ...layer.columns.col1.params, + sortField: 'bytes', + }, + } as LastValueIndexPatternColumn, + }, + }; + expect(lastValueOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([ + 'Field bytes is not a date field and cannot be used for sorting', + ]); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx new file mode 100644 index 0000000000000..5ae5dd472ce22 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { OperationDefinition } from './index'; +import { FieldBasedIndexPatternColumn } from './column_types'; +import { IndexPatternField, IndexPattern } from '../../types'; +import { updateColumnParam } from '../layer_helpers'; +import { DataType } from '../../../types'; +import { getInvalidFieldMessage } from './helpers'; + +function ofName(name: string) { + return i18n.translate('xpack.lens.indexPattern.lastValueOf', { + defaultMessage: 'Last value of {name}', + values: { name }, + }); +} + +const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']); + +export function getInvalidSortFieldMessage(sortField: string, indexPattern?: IndexPattern) { + if (!indexPattern) { + return; + } + const field = indexPattern.getFieldByName(sortField); + if (!field) { + return i18n.translate('xpack.lens.indexPattern.lastValue.sortFieldNotFound', { + defaultMessage: 'Field {invalidField} was not found', + values: { invalidField: sortField }, + }); + } + if (field.type !== 'date') { + return i18n.translate('xpack.lens.indexPattern.lastValue.invalidTypeSortField', { + defaultMessage: 'Field {invalidField} is not a date field and cannot be used for sorting', + values: { invalidField: sortField }, + }); + } +} + +function isTimeFieldNameDateField(indexPattern: IndexPattern) { + return ( + indexPattern.timeFieldName && + indexPattern.fields.find( + (field) => field.name === indexPattern.timeFieldName && field.type === 'date' + ) + ); +} + +function getDateFields(indexPattern: IndexPattern): IndexPatternField[] { + const dateFields = indexPattern.fields.filter((field) => field.type === 'date'); + if (isTimeFieldNameDateField(indexPattern)) { + dateFields.sort(({ name: nameA }, { name: nameB }) => { + if (nameA === indexPattern.timeFieldName) { + return -1; + } + if (nameB === indexPattern.timeFieldName) { + return 1; + } + return 0; + }); + } + return dateFields; +} + +export interface LastValueIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'last_value'; + params: { + sortField: string; + // last value on numeric fields can be formatted + format?: { + id: string; + params?: { + decimals: number; + }; + }; + }; +} + +export const lastValueOperation: OperationDefinition = { + type: 'last_value', + displayName: i18n.translate('xpack.lens.indexPattern.lastValue', { + defaultMessage: 'Last value', + }), + getDefaultLabel: (column, indexPattern) => + indexPattern.getFieldByName(column.sourceField)!.displayName, + input: 'field', + onFieldChange: (oldColumn, field) => { + const newParams = { ...oldColumn.params }; + + if ('format' in newParams && field.type !== 'number') { + delete newParams.format; + } + return { + ...oldColumn, + dataType: field.type as DataType, + label: ofName(field.displayName), + sourceField: field.name, + params: newParams, + }; + }, + getPossibleOperationForField: ({ aggregationRestrictions, type }) => { + if (supportedTypes.has(type) && !aggregationRestrictions) { + return { + dataType: type as DataType, + isBucketed: false, + scale: type === 'string' ? 'ordinal' : 'ratio', + }; + } + }, + getDisabledStatus(indexPattern: IndexPattern) { + const hasDateFields = indexPattern && getDateFields(indexPattern).length; + if (!hasDateFields) { + return i18n.translate('xpack.lens.indexPattern.lastValue.disabled', { + defaultMessage: 'This function requires the presence of a date field in your index', + }); + } + }, + getErrorMessage(layer, columnId, indexPattern) { + const column = layer.columns[columnId] as LastValueIndexPatternColumn; + let errorMessages: string[] = []; + const invalidSourceFieldMessage = getInvalidFieldMessage(column, indexPattern); + const invalidSortFieldMessage = getInvalidSortFieldMessage( + column.params.sortField, + indexPattern + ); + if (invalidSourceFieldMessage) { + errorMessages = [...invalidSourceFieldMessage]; + } + if (invalidSortFieldMessage) { + errorMessages = [invalidSortFieldMessage]; + } + return errorMessages.length ? errorMessages : undefined; + }, + buildColumn({ field, previousColumn, indexPattern }) { + const sortField = isTimeFieldNameDateField(indexPattern) + ? indexPattern.timeFieldName + : indexPattern.fields.find((f) => f.type === 'date')?.name; + + if (!sortField) { + throw new Error( + i18n.translate('xpack.lens.functions.lastValue.missingSortField', { + defaultMessage: 'This index pattern does not contain any date fields', + }) + ); + } + + return { + label: ofName(field.displayName), + dataType: field.type as DataType, + operationType: 'last_value', + isBucketed: false, + scale: field.type === 'string' ? 'ordinal' : 'ratio', + sourceField: field.name, + params: { + sortField, + }, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + schema: 'metric', + type: 'top_hits', + params: { + field: column.sourceField, + aggregate: 'concat', + size: 1, + sortOrder: 'desc', + sortField: column.params.sortField, + }, + }), + + isTransferable: (column, newIndexPattern) => { + const newField = newIndexPattern.getFieldByName(column.sourceField); + const newTimeField = newIndexPattern.getFieldByName(column.params.sortField); + return Boolean( + newField && + newField.type === column.dataType && + !newField.aggregationRestrictions && + newTimeField?.type === 'date' + ); + }, + + paramEditor: ({ state, setState, currentColumn, layerId }) => { + const currentIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; + const dateFields = getDateFields(currentIndexPattern); + const isSortFieldInvalid = !!getInvalidSortFieldMessage( + currentColumn.params.sortField, + currentIndexPattern + ); + return ( + <> + + { + return { + value: field.name, + label: field.displayName, + }; + })} + onChange={(choices) => { + if (choices.length === 0) { + return; + } + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'sortField', + value: choices[0].value, + }) + ); + }} + selectedOptions={ + ((currentColumn.params?.sortField + ? [ + { + label: + currentIndexPattern.getFieldByName(currentColumn.params.sortField) + ?.displayName || currentColumn.params.sortField, + value: currentColumn.params.sortField, + }, + ] + : []) as unknown) as EuiComboBoxOptionOption[] + } + /> + + + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index 45ba721981ed5..10a0b915b552d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import { OperationDefinition } from './index'; +import { getInvalidFieldMessage } from './helpers'; import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn, @@ -103,6 +104,8 @@ function buildMetricOperation>({ missing: 0, }, }), + getErrorMessage: (layer, columnId, indexPattern) => + getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), } as OperationDefinition; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx index d2456e1c8d375..f2d3435cc52c0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx @@ -17,6 +17,7 @@ import { mergeLayer } from '../../../state_helpers'; import { supportedFormats } from '../../../format_column'; import { MODES, AUTO_BARS, DEFAULT_INTERVAL, MIN_HISTOGRAM_BARS, SLICES } from './constants'; import { IndexPattern, IndexPatternField } from '../../../types'; +import { getInvalidFieldMessage } from '../helpers'; type RangeType = Omit; // Try to cover all possible serialized states for ranges @@ -109,6 +110,8 @@ export const rangeOperation: OperationDefinition + getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( type === 'number' && diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 7c69a70c09351..e8351ea1e1d09 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -22,6 +22,7 @@ import { DataType } from '../../../../types'; import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; import { ValuesRangeInput } from './values_range_input'; +import { getInvalidFieldMessage } from '../helpers'; function ofName(name: string) { return i18n.translate('xpack.lens.indexPattern.termsOf', { @@ -31,7 +32,7 @@ function ofName(name: string) { } function isSortableByColumn(column: IndexPatternColumn) { - return !column.isBucketed; + return !column.isBucketed && column.operationType !== 'last_value'; } const DEFAULT_SIZE = 3; @@ -71,6 +72,8 @@ export const termsOperation: OperationDefinition + getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), isTransferable: (column, newIndexPattern) => { const newField = newIndexPattern.getFieldByName(column.sourceField); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index e43c7bbd2f72e..0af0f9a9d8613 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -15,7 +15,7 @@ import { createMockedIndexPattern } from '../../../mocks'; import { ValuesRangeInput } from './values_range_input'; import { TermsIndexPatternColumn } from '.'; import { termsOperation } from '../index'; -import { IndexPatternPrivateState, IndexPattern } from '../../../types'; +import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from '../../../types'; const defaultProps = { storage: {} as IStorageWrapper, @@ -368,7 +368,7 @@ describe('terms', () => { }); describe('onOtherColumnChanged', () => { - it('should keep the column if order by column still exists and is metric', () => { + it('should keep the column if order by column still exists and is isSortableByColumn metric', () => { const initialColumn: TermsIndexPatternColumn = { label: 'Top value of category', dataType: 'string', @@ -395,6 +395,40 @@ describe('terms', () => { expect(updatedColumn).toBe(initialColumn); }); + it('should switch to alphabetical ordering if metric is of type last_value', () => { + const initialColumn: TermsIndexPatternColumn = { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col1' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }; + const updatedColumn = termsOperation.onOtherColumnChanged!(initialColumn, { + col1: { + label: 'Last Value', + dataType: 'number', + isBucketed: false, + sourceField: 'bytes', + operationType: 'last_value', + params: { + sortField: 'time', + }, + }, + }); + expect(updatedColumn.params).toEqual( + expect.objectContaining({ + orderBy: { type: 'alphabetical' }, + }) + ); + }); + it('should switch to alphabetical ordering if there are no columns to order by', () => { const termsColumn = termsOperation.onOtherColumnChanged!( { @@ -770,4 +804,49 @@ describe('terms', () => { }); }); }); + describe('getErrorMessage', () => { + let indexPattern: IndexPattern; + let layer: IndexPatternLayer; + beforeEach(() => { + indexPattern = createMockedIndexPattern(); + layer = { + columns: { + col1: { + dataType: 'boolean', + isBucketed: true, + label: 'Top values of bytes', + operationType: 'terms', + params: { + missingBucket: false, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + otherBucket: true, + size: 5, + }, + scale: 'ordinal', + sourceField: 'bytes', + }, + }, + columnOrder: [], + indexPatternId: '', + }; + }); + it('returns undefined if sourceField exists in index pattern', () => { + expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual(undefined); + }); + it('returns error message if the sourceField does not exist in index pattern', () => { + layer = { + ...layer, + columns: { + col1: { + ...layer.columns.col1, + sourceField: 'notExisting', + } as TermsIndexPatternColumn, + }, + }; + expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([ + 'Field notExisting was not found', + ]); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 7ffbeac39c6f5..93447053a6029 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -1706,7 +1706,6 @@ describe('state_helpers', () => { describe('getErrorMessages', () => { it('should collect errors from the operation definitions', () => { const mock = jest.fn().mockReturnValue(['error 1']); - // @ts-expect-error not statically analyzed operationDefinitionMap.testReference.getErrorMessage = mock; const errors = getErrorMessages({ indexPatternId: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts index c3f7dac03ada3..33af8842648f8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts @@ -35,5 +35,6 @@ export const createMockedReferenceOperation = () => { toExpression: jest.fn().mockReturnValue([]), getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }), getDefaultLabel: jest.fn().mockReturnValue('Default label'), + getErrorMessage: jest.fn(), }; }; 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 63d0fd3d4e5c5..9f2b8eab4e09b 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 @@ -293,6 +293,25 @@ describe('getOperationTypesForField', () => { "operationType": "median", "type": "field", }, + Object { + "field": "bytes", + "operationType": "last_value", + "type": "field", + }, + ], + }, + Object { + "operationMetaData": Object { + "dataType": "string", + "isBucketed": false, + "scale": "ordinal", + }, + "operations": Array [ + Object { + "field": "source", + "operationType": "last_value", + "type": "field", + }, ], }, ] diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts index 01b834610eb1a..5f4865ca0ac32 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts @@ -11,7 +11,9 @@ import { BaseIndexPatternColumn, FieldBasedIndexPatternColumn, } from './operations/definitions/column_types'; -import { operationDefinitionMap, OperationType } from './operations'; +import { operationDefinitionMap, IndexPatternColumn } from './operations'; + +import { getInvalidFieldMessage } from './operations/definitions/helpers'; /** * Normalizes the specified operation type. (e.g. document operations @@ -42,60 +44,46 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg ); } -export function hasInvalidFields(state: IndexPatternPrivateState) { +export function hasInvalidColumns(state: IndexPatternPrivateState) { return getInvalidLayers(state).length > 0; } export function getInvalidLayers(state: IndexPatternPrivateState) { return Object.values(state.layers).filter((layer) => { - return layer.columnOrder.some((columnId) => { - const column = layer.columns[columnId]; - return ( - hasField(column) && - fieldIsInvalid( - column.sourceField, - column.operationType, - state.indexPatterns[layer.indexPatternId] - ) - ); - }); + return layer.columnOrder.some((columnId) => + isColumnInvalid(layer, columnId, state.indexPatterns[layer.indexPatternId]) + ); }); } -export function getInvalidFieldsForLayer( +export function getInvalidColumnsForLayer( layers: IndexPatternLayer[], indexPatternMap: Record ) { return layers.map((layer) => { - return layer.columnOrder.filter((columnId) => { - const column = layer.columns[columnId]; - return ( - hasField(column) && - fieldIsInvalid( - column.sourceField, - column.operationType, - indexPatternMap[layer.indexPatternId] - ) - ); - }); + return layer.columnOrder.filter((columnId) => + isColumnInvalid(layer, columnId, indexPatternMap[layer.indexPatternId]) + ); }); } -export function fieldIsInvalid( - sourceField: string | undefined, - operationType: OperationType | undefined, +export function isColumnInvalid( + layer: IndexPatternLayer, + columnId: string, indexPattern: IndexPattern ) { - const operationDefinition = operationType && operationDefinitionMap[operationType]; - const field = sourceField ? indexPattern.getFieldByName(sourceField) : undefined; + const column = layer.columns[columnId]; - return Boolean( - sourceField && - operationDefinition && - !( - field && - operationDefinition?.input === 'field' && - operationDefinition.getPossibleOperationForField(field) !== undefined - ) + const operationDefinition = column.operationType && operationDefinitionMap[column.operationType]; + return !!( + operationDefinition.getErrorMessage && + operationDefinition.getErrorMessage(layer, columnId, indexPattern) ); } + +export function fieldIsInvalid(column: IndexPatternColumn | undefined, indexPattern: IndexPattern) { + if (!column || !hasField(column)) { + return false; + } + return !!getInvalidFieldMessage(column, indexPattern)?.length; +} diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 91f0ddb54ad41..2d9a345b978ec 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -245,6 +245,34 @@ export const getPieVisualization = ({ ); }, + getWarningMessages(state, frame) { + if (state?.layers.length === 0 || !frame.activeData) { + return; + } + + const metricColumnsWithArrayValues = []; + + for (const layer of state.layers) { + const { layerId, metric } = layer; + const rows = frame.activeData[layerId] && frame.activeData[layerId].rows; + if (!rows || !metric) { + break; + } + const columnToLabel = frame.datasourceLayers[layerId].getOperationForColumnId(metric)?.label; + + const hasArrayValues = rows.some((row) => Array.isArray(row[metric])); + if (hasArrayValues) { + metricColumnsWithArrayValues.push(columnToLabel || metric); + } + } + return metricColumnsWithArrayValues.map((label) => ( + <> + {label} contains array values. Your visualization may not render as + expected. + + )); + }, + getErrorMessages(state, frame) { // not possible to break it? return undefined; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 2b3cd02c09d1e..ba459a73ea0ee 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -598,6 +598,11 @@ export interface Visualization { state: T, frame: FramePublicAPI ) => Array<{ shortMessage: string; longMessage: string }> | undefined; + + /** + * The frame calls this function to display warnings about visualization + */ + getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined; } export interface LensFilterEvent { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 546cf06d4014e..d780ce85bad69 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -775,4 +775,69 @@ describe('xy_visualization', () => { ]); }); }); + + describe('#getWarningMessages', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'd' }, + { columnId: 'a' }, + { columnId: 'b' }, + { columnId: 'c' }, + ]); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + frame.activeData = { + first: { + type: 'datatable', + columns: [ + { id: 'a', name: 'A', meta: { type: 'number' } }, + { id: 'b', name: 'B', meta: { type: 'number' } }, + ], + rows: [ + { a: 1, b: [2, 0] }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }, + }; + }); + it('should return a warning when numeric accessors contain array', () => { + (frame.datasourceLayers.first.getOperationForColumnId as jest.Mock).mockReturnValue({ + label: 'Label B', + }); + const warningMessages = xyVisualization.getWarningMessages!( + { + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: ['b'], + }, + ], + }, + frame + ); + expect(warningMessages).toHaveLength(1); + expect(warningMessages && warningMessages[0]).toMatchInlineSnapshot(` + + + Label B + + contains array values. Your visualization may not render as expected. + + `); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index f0dcaf589b1c4..ebf80c61e0cd1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -22,6 +22,7 @@ import { LensIconChartBarStacked } from '../assets/chart_bar_stacked'; import { LensIconChartMixedXy } from '../assets/chart_mixed_xy'; import { LensIconChartBarHorizontal } from '../assets/chart_bar_horizontal'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; +import { getColumnToLabelMap } from './state_helpers'; const defaultIcon = LensIconChartBarStacked; const defaultSeriesType = 'bar_stacked'; @@ -371,6 +372,37 @@ export const getXyVisualization = ({ return errors.length ? errors : undefined; }, + + getWarningMessages(state, frame) { + if (state?.layers.length === 0 || !frame.activeData) { + return; + } + + const layers = state.layers; + + const filteredLayers = layers.filter(({ accessors }: LayerConfig) => accessors.length > 0); + const accessorsWithArrayValues = []; + for (const layer of filteredLayers) { + const { layerId, accessors } = layer; + const rows = frame.activeData[layerId] && frame.activeData[layerId].rows; + if (!rows) { + break; + } + const columnToLabel = getColumnToLabelMap(layer, frame.datasourceLayers[layerId]); + for (const accessor of accessors) { + const hasArrayValues = rows.some((row) => Array.isArray(row[accessor])); + if (hasArrayValues) { + accessorsWithArrayValues.push(columnToLabel[accessor]); + } + } + } + return accessorsWithArrayValues.map((label) => ( + <> + {label} contains array values. Your visualization may not render as + expected. + + )); + }, }); function validateLayersForDimension(