diff --git a/src/plugins/data/common/search/aggs/buckets/multi_terms.ts b/src/plugins/data/common/search/aggs/buckets/multi_terms.ts index c320c7e242798..02bf6bd12d319 100644 --- a/src/plugins/data/common/search/aggs/buckets/multi_terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/multi_terms.ts @@ -34,6 +34,7 @@ export interface AggParamsMultiTerms extends BaseAggParams { size?: number; otherBucket?: boolean; otherBucketLabel?: string; + separatorLabel?: string; } export const getMultiTermsBucketAgg = () => { @@ -83,6 +84,7 @@ export const getMultiTermsBucketAgg = () => { params: { otherBucketLabel: params.otherBucketLabel, paramsPerField: formats, + separator: agg.params.separatorLabel, }, }; }, @@ -142,6 +144,11 @@ export const getMultiTermsBucketAgg = () => { shouldShow: (agg) => agg.getParam('otherBucket'), write: noop, }, + { + name: 'separatorLabel', + type: 'string', + write: noop, + }, ], }); }; diff --git a/src/plugins/data/common/search/aggs/buckets/multi_terms_fn.ts b/src/plugins/data/common/search/aggs/buckets/multi_terms_fn.ts index 58e49479cd2c1..12b9c6d156548 100644 --- a/src/plugins/data/common/search/aggs/buckets/multi_terms_fn.ts +++ b/src/plugins/data/common/search/aggs/buckets/multi_terms_fn.ts @@ -111,6 +111,12 @@ export const aggMultiTerms = (): FunctionDefinition => ({ defaultMessage: 'Represents a custom label for this aggregation', }), }, + separatorLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.multiTerms.separatorLabel.help', { + defaultMessage: 'The separator label used to join each term combination', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts index 76112980c55fb..8510acf1572c7 100644 --- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts +++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.test.ts @@ -13,6 +13,7 @@ import { IFieldFormat, SerializedFieldFormat, } from '../../../../../field_formats/common'; +import { MultiFieldKey } from '../buckets/multi_field_key'; import { getAggsFormats } from './get_aggs_formats'; const getAggFormat = ( @@ -119,4 +120,35 @@ describe('getAggsFormats', () => { expect(format.convert('__missing__')).toBe(mapping.params.missingBucketLabel); expect(getFormat).toHaveBeenCalledTimes(3); }); + + test('uses a default separator for multi terms', () => { + const terms = ['source', 'geo.src', 'geo.dest']; + const mapping = { + id: 'multi_terms', + params: { + paramsPerField: Array(terms.length).fill({ id: 'terms' }), + }, + }; + + const format = getAggFormat(mapping, getFormat); + + expect(format.convert(new MultiFieldKey({ key: terms }))).toBe('source › geo.src › geo.dest'); + expect(getFormat).toHaveBeenCalledTimes(terms.length); + }); + + test('uses a custom separator for multi terms when passed', () => { + const terms = ['source', 'geo.src', 'geo.dest']; + const mapping = { + id: 'multi_terms', + params: { + paramsPerField: Array(terms.length).fill({ id: 'terms' }), + separator: ' - ', + }, + }; + + const format = getAggFormat(mapping, getFormat); + + expect(format.convert(new MultiFieldKey({ key: terms }))).toBe('source - geo.src - geo.dest'); + expect(getFormat).toHaveBeenCalledTimes(terms.length); + }); }); diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts index aade8bc70e4ee..f14f981fdec65 100644 --- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts +++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts @@ -143,9 +143,11 @@ export function getAggsFormats(getFieldFormat: GetFieldFormat): FieldFormatInsta return params.otherBucketLabel; } + const joinTemplate = params.separator ?? ' › '; + return (val as MultiFieldKey).keys .map((valPart, i) => formats[i].convert(valPart, type)) - .join(' › '); + .join(joinTemplate); }; getConverterFor = (type: FieldFormatsContentType) => (val: string) => this.convert(val, type); }, diff --git a/x-pack/plugins/lens/jest.config.js b/x-pack/plugins/lens/jest.config.js index f1164df4eab86..ae4a39557bec8 100644 --- a/x-pack/plugins/lens/jest.config.js +++ b/x-pack/plugins/lens/jest.config.js @@ -12,4 +12,5 @@ module.exports = { coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/lens', coverageReporters: ['text', 'html'], collectCoverageFrom: ['/x-pack/plugins/lens/{common,public,server}/**/*.{ts,tsx}'], + setupFiles: ['jest-canvas-mock'], }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 37191ffa89fdc..f7fcc012ec168 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -475,6 +475,7 @@ describe('editor_frame', () => { datasourceId: 'testDatasource', getOperationForColumnId: jest.fn(), getTableSpec: jest.fn(), + getVisualDefaults: jest.fn(), }; mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index 0d68e2d72e73b..9d1e5910b468d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -540,6 +540,7 @@ describe('suggestion helpers', () => { getTableSpec: () => [{ columnId: 'col1' }], datasourceId: '', getOperationForColumnId: jest.fn(), + getVisualDefaults: jest.fn(), }, }, { activeId: 'testVis', state: {} }, @@ -597,6 +598,7 @@ describe('suggestion helpers', () => { getTableSpec: () => [], datasourceId: '', getOperationForColumnId: jest.fn(), + getVisualDefaults: jest.fn(), }, }; mockVisualization1.getSuggestions.mockReturnValue([]); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx index 88966acd22691..a4806e0849db4 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import type { VisualizationToolbarProps } from '../types'; import { LegendSettingsPopover, ToolbarPopover, ValueLabelsSettings } from '../shared_components'; import type { HeatmapVisualizationState } from './types'; +import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [ { @@ -32,9 +33,13 @@ const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: export const HeatmapToolbar = memo( (props: VisualizationToolbarProps) => { - const { state, setState } = props; + const { state, setState, frame } = props; const legendMode = state.legend.isVisible ? 'show' : 'hide'; + const defaultTruncationValue = getDefaultVisualValuesForLayer( + state, + frame.datasourceLayers + ).truncateText; return ( @@ -90,9 +95,9 @@ export const HeatmapToolbar = memo( legend: { ...state.legend, maxLines: val }, }); }} - shouldTruncate={state?.legend.shouldTruncate ?? true} + shouldTruncate={state?.legend.shouldTruncate ?? defaultTruncationValue} onTruncateLegendChange={() => { - const current = state.legend.shouldTruncate ?? true; + const current = state.legend.shouldTruncate ?? defaultTruncationValue; setState({ ...state, legend: { ...state.legend, shouldTruncate: !current }, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index 68039e79ea45c..6e61bb684fa91 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -68,7 +68,6 @@ describe('heatmap', () => { position: Position.Right, type: LEGEND_FUNCTION, maxLines: 1, - shouldTruncate: true, }, gridConfig: { type: HEATMAP_GRID_FUNCTION, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index bf645599cae11..6a654a020bc23 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -74,7 +74,6 @@ function getInitialState(): Omit
@@ -491,61 +480,34 @@ export function DimensionEditor(props: DimensionEditorProps) { {!selectedColumn || selectedOperationDefinition?.input === 'field' || - (incompleteOperation && operationDefinitionMap[incompleteOperation].input === 'field') || + (incompleteOperation && operationDefinitionMap[incompleteOperation]?.input === 'field') || temporaryQuickFunction ? ( - - { + if (temporaryQuickFunction) { + setTemporaryState('none'); } - incompleteOperation={incompleteOperation} - onChoose={(choice) => { - if (temporaryQuickFunction) { - setTemporaryState('none'); - } - setStateWrapper( - insertOrReplaceColumn({ - layer: state.layers[layerId], - columnId, - indexPattern: currentIndexPattern, - op: choice.operationType, - field: currentIndexPattern.getFieldByName(choice.field), - visualizationGroups: dimensionGroups, - targetGroup: props.groupId, - incompleteParams, - }), - { forceRender: temporaryQuickFunction } - ); - }} - /> - + setStateWrapper(newLayer, { forceRender: temporaryQuickFunction }); + }} + incompleteField={incompleteField} + incompleteOperation={incompleteOperation} + incompleteParams={incompleteParams} + currentFieldIsInvalid={currentFieldIsInvalid} + helpMessage={selectedOperationDefinition?.getHelpMessage?.({ + data: props.data, + uiSettings: props.uiSettings, + currentColumn: state.layers[layerId].columns[columnId], + })} + dimensionGroups={dimensionGroups} + groupId={props.groupId} + operationDefinitionMap={operationDefinitionMap} + /> ) : null} {shouldDisplayExtraOptions && ParamEditor && ( @@ -553,7 +515,12 @@ export function DimensionEditor(props: DimensionEditorProps) { layer={state.layers[layerId]} layerId={layerId} activeData={props.activeData} - updateLayer={setStateWrapper} + updateLayer={(setter) => { + if (temporaryQuickFunction) { + setTemporaryState('none'); + } + setStateWrapper(setter, { forceRender: temporaryQuickFunction }); + }} columnId={columnId} currentColumn={state.layers[layerId].columns[columnId]} dateRange={dateRange} 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 2ff2fd67435ab..dd16b0be6ce61 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 @@ -6,7 +6,6 @@ */ import { ReactWrapper, ShallowWrapper } from 'enzyme'; -import 'jest-canvas-mock'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx index bcfa0d797b51c..033ac9c707151 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx @@ -16,7 +16,7 @@ import './dimension_editor.scss'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiFieldText, EuiTabs, EuiTab, EuiCallOut } from '@elastic/eui'; -import { GenericIndexPatternColumn, operationDefinitionMap } from '../operations'; +import { operationDefinitionMap } from '../operations'; import { useDebouncedValue } from '../../shared_components'; export const formulaOperationName = 'formula'; @@ -174,26 +174,3 @@ export const DimensionEditorTabs = ({ tabs }: { tabs: DimensionEditorTab[] }) => ); }; - -export function getErrorMessage( - selectedColumn: GenericIndexPatternColumn | undefined, - incompleteOperation: boolean, - input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined, - fieldInvalid: boolean -) { - if (selectedColumn && incompleteOperation) { - if (input === 'field') { - return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { - defaultMessage: 'This field does not work with the selected function.', - }); - } - return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { - defaultMessage: 'To use this function, select a field.', - }); - } - if (fieldInvalid) { - return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', { - defaultMessage: 'Invalid field. Check your data view or pick another field.', - }); - } -} 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 fcc9a57285ba6..87daef0d40f62 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 @@ -13,7 +13,7 @@ import { VisualizationDimensionGroupConfig, } from '../../../types'; import { getOperationDisplay } from '../../operations'; -import { hasField, isDraggedField } from '../../utils'; +import { hasField, isDraggedField } from '../../pure_utils'; import { DragContextState } from '../../../drag_drop/providers'; import { OperationMetadata } from '../../../types'; import { getOperationTypesForField } from '../../operations'; 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 0c538d0fc9486..1b5679786e717 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 @@ -13,7 +13,7 @@ import { copyColumn, } from '../../operations'; import { mergeLayer } from '../../state_helpers'; -import { isDraggedField } from '../../utils'; +import { isDraggedField } from '../../pure_utils'; import { getNewOperation, getField } from './get_drop_props'; import { IndexPatternPrivateState, DraggedField } from '../../types'; import { trackUiEvent } from '../../../lens_ui_telemetry'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx new file mode 100644 index 0000000000000..cf409ebfd680d --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.test.tsx @@ -0,0 +1,487 @@ +/* + * 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 { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { EuiComboBox } from '@elastic/eui'; +import { GenericOperationDefinition } from '../operations'; +import { + averageOperation, + countOperation, + derivativeOperation, + FieldBasedIndexPatternColumn, + termsOperation, + staticValueOperation, +} from '../operations/definitions'; +import { FieldInput, getErrorMessage } from './field_input'; +import { createMockedIndexPattern } from '../mocks'; +import { getOperationSupportMatrix } from '.'; +import { GenericIndexPatternColumn, IndexPatternLayer, IndexPatternPrivateState } from '../types'; +import { ReferenceBasedIndexPatternColumn } from '../operations/definitions/column_types'; + +jest.mock('../operations/layer_helpers', () => { + const original = jest.requireActual('../operations/layer_helpers'); + + return { + ...original, + insertOrReplaceColumn: () => { + return {} as IndexPatternLayer; + }, + }; +}); + +const defaultProps = { + indexPattern: createMockedIndexPattern(), + currentFieldIsInvalid: false, + incompleteField: null, + incompleteOperation: undefined, + incompleteParams: {}, + dimensionGroups: [], + groupId: 'any', + operationDefinitionMap: { + terms: termsOperation, + average: averageOperation, + count: countOperation, + differences: derivativeOperation, + staticValue: staticValueOperation, + } as unknown as Record, +}; + +function getStringBasedOperationColumn(field = 'source'): FieldBasedIndexPatternColumn { + return { + label: `Top value of ${field}`, + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: field, + } as FieldBasedIndexPatternColumn; +} + +function getReferenceBasedOperationColumn( + subOp = 'average', + field = 'bytes' +): ReferenceBasedIndexPatternColumn { + return { + label: `Difference of ${subOp} of ${field}`, + dataType: 'number', + operationType: 'differences', + isBucketed: false, + references: ['colX'], + scale: 'ratio', + }; +} + +function getManagedBasedOperationColumn(): ReferenceBasedIndexPatternColumn { + return { + label: 'Static value: 100', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: 100 }, + references: [], + } as ReferenceBasedIndexPatternColumn; +} + +function getCountOperationColumn(): GenericIndexPatternColumn { + return { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }; +} +function getLayer(col1: GenericIndexPatternColumn = getStringBasedOperationColumn()) { + return { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1, + col2: getCountOperationColumn(), + }, + }; +} +function getDefaultOperationSupportMatrix( + layer: IndexPatternLayer, + columnId: string, + existingFields: Record> +) { + return getOperationSupportMatrix({ + state: { + layers: { layer1: layer }, + indexPatterns: { + [defaultProps.indexPattern.id]: defaultProps.indexPattern, + }, + existingFields, + } as unknown as IndexPatternPrivateState, + layerId: 'layer1', + filterOperations: () => true, + columnId, + }); +} + +function getExistingFields(layer: IndexPatternLayer) { + const fields: Record = {}; + for (const field of defaultProps.indexPattern.fields) { + fields[field.name] = true; + } + return { + [layer.indexPatternId]: fields, + }; +} + +describe('FieldInput', () => { + it('should render a field select box', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect(instance.find('[data-test-subj="indexPattern-dimension-field"]').exists()).toBeTruthy(); + }); + + it('should render an error message when incomplete operation is on', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('isInvalid') + ).toBeTruthy(); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('This field does not work with the selected function.'); + }); + + it.each([ + ['reference-based operation', getReferenceBasedOperationColumn()], + ['managed references operation', getManagedBasedOperationColumn()], + ])( + 'should mark the field as invalid but not show any error message for a %s when only an incomplete column is set', + (_, col: ReferenceBasedIndexPatternColumn) => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(col); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix( + layer, + 'col1', + existingFields + ); + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeTruthy(); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe(undefined); + } + ); + + it.each([ + ['reference-based operation', getReferenceBasedOperationColumn()], + ['managed references operation', getManagedBasedOperationColumn()], + ])( + 'should mark the field as invalid but and show an error message for a %s when an incomplete column is set and an existing column is selected', + (_, col: ReferenceBasedIndexPatternColumn) => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(col); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix( + layer, + 'col1', + existingFields + ); + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeTruthy(); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('This field does not work with the selected function.'); + } + ); + + it('should render an error message for invalid fields', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('isInvalid') + ).toBeTruthy(); + + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('Invalid field. Check your data view or pick another field.'); + }); + + it('should render a help message when passed and no errors are found', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('labelAppend') + ).toBe('My help message'); + }); + + it('should prioritize errors over help messages', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('labelAppend') + ).not.toBe('My help message'); + }); + + it('should update the layer on field selection', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + act(() => { + instance.find(EuiComboBox).first().prop('onChange')!([ + { value: { type: 'field', field: 'dest' }, label: 'dest' }, + ]); + }); + + expect(updateLayerSpy).toHaveBeenCalled(); + }); + + it('should not trigger when the same selected field is selected again', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + act(() => { + instance.find(EuiComboBox).first().prop('onChange')!([ + { value: { type: 'field', field: 'source' }, label: 'source' }, + ]); + }); + + expect(updateLayerSpy).not.toHaveBeenCalled(); + }); + + it('should prioritize incomplete fields over selected column field to display', () => { + const updateLayerSpy = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + expect(instance.find(EuiComboBox).first().prop('selectedOptions')).toEqual([ + { + label: 'dest', + value: { type: 'field', field: 'dest' }, + }, + ]); + }); + + it('should forward the onDeleteColumn function', () => { + const updateLayerSpy = jest.fn(); + const onDeleteColumn = jest.fn(); + const layer = getLayer(); + const existingFields = getExistingFields(layer); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const instance = mount( + + ); + + act(() => { + instance.find(EuiComboBox).first().prop('onChange')!([]); + }); + + expect(onDeleteColumn).toHaveBeenCalled(); + expect(updateLayerSpy).not.toHaveBeenCalled(); + }); +}); + +describe('getErrorMessage', () => { + it.each(['none', 'field', 'fullReference', 'managedReference'] as const)( + 'should return no error for no column passed for %s type of operation', + (type) => { + expect(getErrorMessage(undefined, false, type, false)).toBeUndefined(); + } + ); + + it('should return the invalid message', () => { + expect(getErrorMessage(undefined, false, 'none', true)).toBe( + 'Invalid field. Check your data view or pick another field.' + ); + }); + + it('should ignore the invalid flag when an incomplete column is passed', () => { + expect( + getErrorMessage( + { operationType: 'terms', label: 'Top values of X', dataType: 'string', isBucketed: true }, + true, + 'field', + true + ) + ).not.toBe('Invalid field. Check your data view or pick another field.'); + }); + + it('should tell the user to change field if incomplete with an incompatible field', () => { + expect( + getErrorMessage( + { operationType: 'terms', label: 'Top values of X', dataType: 'string', isBucketed: true }, + true, + 'field', + false + ) + ).toBe('This field does not work with the selected function.'); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.tsx new file mode 100644 index 0000000000000..ad3aa97b2a0ea --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_input.tsx @@ -0,0 +1,114 @@ +/* + * 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 { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { insertOrReplaceColumn } from '../operations/layer_helpers'; +import { FieldSelect } from './field_select'; +import type { + FieldInputProps, + OperationType, + GenericIndexPatternColumn, +} from '../operations/definitions'; +import type { FieldBasedIndexPatternColumn } from '../operations/definitions/column_types'; + +export function FieldInput({ + layer, + selectedColumn, + columnId, + indexPattern, + existingFields, + operationSupportMatrix, + updateLayer, + onDeleteColumn, + incompleteField, + incompleteOperation, + incompleteParams, + currentFieldIsInvalid, + helpMessage, + groupId, + dimensionGroups, + operationDefinitionMap, +}: FieldInputProps) { + const selectedOperationDefinition = + selectedColumn && operationDefinitionMap[selectedColumn.operationType]; + // Need to workout early on the error to decide whether to show this or an help text + const fieldErrorMessage = + ((selectedOperationDefinition?.input !== 'fullReference' && + selectedOperationDefinition?.input !== 'managedReference') || + (incompleteOperation && operationDefinitionMap[incompleteOperation].input === 'field')) && + getErrorMessage( + selectedColumn, + Boolean(incompleteOperation), + selectedOperationDefinition?.input, + currentFieldIsInvalid + ); + return ( + + { + return updateLayer( + insertOrReplaceColumn({ + layer, + columnId, + indexPattern, + op: choice.operationType, + field: indexPattern.getFieldByName(choice.field), + visualizationGroups: dimensionGroups, + targetGroup: groupId, + incompleteParams, + }) + ); + }} + /> + + ); +} + +export function getErrorMessage( + selectedColumn: GenericIndexPatternColumn | undefined, + incompleteOperation: boolean, + input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined, + fieldInvalid: boolean +) { + if (selectedColumn && incompleteOperation) { + if (input === 'field') { + return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { + defaultMessage: 'This field does not work with the selected function.', + }); + } + return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { + defaultMessage: 'To use this function, select a field.', + }); + } + if (fieldInvalid) { + return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', { + defaultMessage: 'Invalid field. Check your data view or pick another field.', + }); + } +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index 003af1f3ed4a7..f775026d54921 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -18,14 +18,14 @@ import { EuiComboBoxProps, } from '@elastic/eui'; import classNames from 'classnames'; -import { OperationType } from '../indexpattern'; import { LensFieldIcon } from '../lens_field_icon'; -import { DataType } from '../../types'; -import { OperationSupportMatrix } from './operation_support'; -import { IndexPattern, IndexPatternPrivateState } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { fieldExists } from '../pure_helpers'; import { TruncatedLabel } from './truncated_label'; +import type { OperationType } from '../indexpattern'; +import type { DataType } from '../../types'; +import type { OperationSupportMatrix } from './operation_support'; +import type { IndexPattern, IndexPatternPrivateState } from '../types'; export interface FieldChoice { type: 'field'; field: string; @@ -37,12 +37,13 @@ export interface FieldSelectProps extends EuiComboBoxProps void; onDeleteColumn?: () => void; existingFields: IndexPatternPrivateState['existingFields']; fieldIsInvalid: boolean; markAllFieldsCompatible?: boolean; + 'data-test-subj'?: string; } const DEFAULT_COMBOBOX_WIDTH = 305; @@ -54,15 +55,15 @@ export function FieldSelect({ incompleteOperation, selectedOperationType, selectedField, - operationSupportMatrix, + operationByField, onChoose, onDeleteColumn, existingFields, fieldIsInvalid, markAllFieldsCompatible, + ['data-test-subj']: dataTestSub, ...rest }: FieldSelectProps) { - const { operationByField } = operationSupportMatrix; const memoizedFieldOptions = useMemo(() => { const fields = Object.keys(operationByField).sort(); @@ -85,6 +86,9 @@ export function FieldSelect({ return items .filter((field) => currentIndexPattern.getFieldByName(field)?.displayName) .map((field) => { + const compatible = + markAllFieldsCompatible || isCompatibleWithCurrentOperation(field) ? 1 : 0; + const exists = containsData(field); return { label: currentIndexPattern.getFieldByName(field)?.displayName, value: { @@ -99,30 +103,18 @@ export function FieldSelect({ ? currentOperationType : operationByField[field]!.values().next().value, }, - exists: containsData(field), - compatible: markAllFieldsCompatible || isCompatibleWithCurrentOperation(field), + exists, + compatible, + className: classNames({ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'lnFieldSelect__option--incompatible': !compatible, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'lnFieldSelect__option--nonExistant': !exists, + }), + 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${field}`, }; }) - .sort((a, b) => { - if (a.compatible && !b.compatible) { - return -1; - } - if (!a.compatible && b.compatible) { - return 1; - } - return 0; - }) - .map(({ label, value, compatible, exists }) => ({ - label, - value, - className: classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention - 'lnFieldSelect__option--incompatible': !compatible, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'lnFieldSelect__option--nonExistant': !exists, - }), - 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${value.field}`, - })); + .sort((a, b) => b.compatible - a.compatible); } const [metaFields, nonMetaFields] = partition( @@ -207,7 +199,7 @@ export function FieldSelect({ fullWidth compressed isClearable={false} - data-test-subj="indexPattern-dimension-field" + data-test-subj={dataTestSub ?? 'indexPattern-dimension-field'} placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', { defaultMessage: 'Field', })} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx index 16251654a6355..4a16739e65972 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx @@ -10,7 +10,6 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { EuiComboBox } from '@elastic/eui'; import { mountWithIntl as mount } from '@kbn/test/jest'; -import 'jest-canvas-mock'; import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import type { DataPublicPluginStart } from 'src/plugins/data/public'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx index 6fa1912effc2a..a59229ad093b6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx @@ -31,11 +31,11 @@ import { RequiredReference, } from '../operations'; import { FieldSelect } from './field_select'; -import { hasField } from '../utils'; +import { hasField } from '../pure_utils'; import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import { ParamEditorCustomProps, VisualizationDimensionGroupConfig } from '../../types'; -import { IndexPatternDimensionEditorProps } from './dimension_panel'; +import type { ParamEditorCustomProps, VisualizationDimensionGroupConfig } from '../../types'; +import type { IndexPatternDimensionEditorProps } from './dimension_panel'; const operationPanels = getOperationDisplay(); @@ -305,7 +305,7 @@ export function ReferenceEditor(props: ReferenceEditorProps) { { 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 d7ea174718813..50f72e5d2cd7b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -6,7 +6,6 @@ */ import React from 'react'; -import 'jest-canvas-mock'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { getIndexPatternDatasource, GenericIndexPatternColumn } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource, FramePublicAPI } from '../types'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 6179f34226125..49a85f3f3af79 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -42,7 +42,8 @@ import { getDatasourceSuggestionsForVisualizeField, } from './indexpattern_suggestions'; -import { isColumnInvalid, isDraggedField, normalizeOperationDataType } from './utils'; +import { getVisualDefaultsForLayer, isColumnInvalid } from './utils'; +import { normalizeOperationDataType, isDraggedField } from './pure_utils'; import { LayerPanel } from './layerpanel'; import { GenericIndexPatternColumn, getErrorMessages, insertNewColumn } from './operations'; import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types'; @@ -448,6 +449,10 @@ export function getIndexPatternDatasource({ } return null; }, + getVisualDefaults: () => { + const layer = state.layers[layerId]; + return getVisualDefaultsForLayer(layer); + }, }; }, getDatasourceSuggestionsForField(state, draggedField, filterLayers) { 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 783314968633f..f9f720cfa922a 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 @@ -8,7 +8,6 @@ import { DatasourceSuggestion } from '../types'; import { generateId } from '../id_generator'; import type { IndexPatternPrivateState } from './types'; -import 'jest-canvas-mock'; import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, 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 6b15a5a8d1daf..3b7d87c00c2da 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -23,7 +23,7 @@ import { getReferencedColumnIds, hasTermsWithManyBuckets, } from './operations'; -import { hasField } from './utils'; +import { hasField } from './pure_utils'; import type { IndexPattern, IndexPatternPrivateState, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx index fa4d3e5e1513d..2bde60c71f53c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { FieldIcon, FieldIconProps } from '@kbn/react-field/field_icon'; import { DataType } from '../types'; -import { normalizeOperationDataType } from './utils'; +import { normalizeOperationDataType } from './pure_utils'; export function LensFieldIcon({ type, ...rest }: FieldIconProps & { type: DataType }) { return ( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index b215e6ed7e318..8d0a07cffd2e1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -173,6 +173,118 @@ describe('filters', () => { }); }); + describe('buildColumn', () => { + it('should build a column with a default query', () => { + expect( + filtersOperation.buildColumn({ + previousColumn: undefined, + layer, + indexPattern: defaultProps.indexPattern, + }) + ).toEqual({ + label: 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: [ + { + input: { + query: '', + language: 'kuery', + }, + label: '', + }, + ], + }, + }); + }); + + it('should inherit terms field when transitioning to filters', () => { + expect( + filtersOperation.buildColumn({ + previousColumn: { + operationType: 'terms', + sourceField: 'bytes', + label: 'Top values of bytes', + isBucketed: true, + dataType: 'number', + params: { + // let's ignore terms params here + format: { id: 'number', params: { decimals: 0 } }, + }, + }, + layer, + indexPattern: defaultProps.indexPattern, + }) + ).toEqual({ + label: 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: [ + { + input: { + query: 'bytes : *', + language: 'kuery', + }, + label: '', + }, + ], + }, + }); + }); + + it('should carry over multi terms as multiple filters', () => { + expect( + filtersOperation.buildColumn({ + previousColumn: { + operationType: 'terms', + sourceField: 'bytes', + label: 'Top values of bytes', + isBucketed: true, + dataType: 'number', + params: { + // let's ignore terms params here + format: { id: 'number', params: { decimals: 0 } }, + // @ts-expect-error not defined in the generic type, only in the Terms specific type + secondaryFields: ['dest'], + }, + }, + layer, + indexPattern: defaultProps.indexPattern, + }) + ).toEqual({ + label: 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: [ + { + input: { + query: 'bytes : *', + language: 'kuery', + }, + label: '', + }, + { + input: { + query: 'dest : *', + language: 'kuery', + }, + label: '', + }, + ], + }, + }); + }); + }); + describe('popover param editor', () => { // @ts-expect-error window['__react-beautiful-dnd-disable-dev-warnings'] = true; // issue with enzyme & react-beautiful-dnd throwing errors: https://github.com/atlassian/react-beautiful-dnd/issues/1593 diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index 08cd12556eaed..f7537cffb112e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -101,6 +101,15 @@ export const filtersOperation: OperationDefinition ({ + label: '', + input: { + query: `${field} : *`, + language: 'kuery', + }, + })) ?? []), ], }; } 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 f18bdb9498f25..275ad1798c788 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 @@ -36,6 +36,7 @@ import { lastValueOperation } from './last_value'; import { FrameDatasourceAPI, OperationMetadata, ParamEditorCustomProps } from '../../../types'; import type { BaseIndexPatternColumn, + IncompleteColumn, GenericIndexPatternColumn, ReferenceBasedIndexPatternColumn, } from './column_types'; @@ -44,7 +45,7 @@ import { DateRange, LayerType } from '../../../../common'; import { ExpressionAstFunction } from '../../../../../../../src/plugins/expressions/public'; import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { rangeOperation } from './ranges'; -import { IndexPatternDimensionEditorProps } from '../../dimension_panel'; +import { IndexPatternDimensionEditorProps, OperationSupportMatrix } from '../../dimension_panel'; export type { IncompleteColumn, @@ -158,6 +159,30 @@ export interface ParamEditorProps { paramEditorCustomProps?: ParamEditorCustomProps; } +export interface FieldInputProps { + layer: IndexPatternLayer; + selectedColumn?: C; + columnId: string; + indexPattern: IndexPattern; + updateLayer: ( + setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + ) => void; + onDeleteColumn?: () => void; + currentFieldIsInvalid: boolean; + incompleteField: IncompleteColumn['sourceField'] | null; + incompleteOperation: IncompleteColumn['operationType']; + incompleteParams: Omit; + dimensionGroups: IndexPatternDimensionEditorProps['dimensionGroups']; + groupId: IndexPatternDimensionEditorProps['groupId']; + /** + * indexPatternId -> fieldName -> boolean + */ + existingFields: Record>; + operationSupportMatrix: OperationSupportMatrix; + helpMessage?: React.ReactNode; + operationDefinitionMap: Record; +} + export interface HelpProps { currentColumn: C; uiSettings: IUiSettingsClient; @@ -199,7 +224,7 @@ interface BaseOperationDefinitionProps { changedColumnId: string ) => C; /** - * React component for operation specific settings shown in the popover editor + * React component for operation specific settings shown in the flyout editor */ paramEditor?: React.ComponentType>; /** @@ -281,6 +306,18 @@ interface BaseOperationDefinitionProps { description: string; section: 'elasticsearch' | 'calculation'; }; + /** + * React component for operation field specific behaviour + */ + renderFieldInput?: React.ComponentType>; + /** + * Verify if the a new field can be added to the column + */ + canAddNewField?: (column: C, field: IndexPatternField) => boolean; + /** + * Operation can influence some visual default settings. This function is used to collect default values offered + */ + getDefaultVisualSettings?: (column: C) => { truncateText?: boolean }; } interface BaseBuildColumnArgs { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx index b2cfc0e5a7c2c..0f4ba342348cf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx @@ -20,12 +20,23 @@ import { EuiButtonEmpty, } from '@elastic/eui'; -export const NewBucketButton = ({ label, onClick }: { label: string; onClick: () => void }) => ( +export const NewBucketButton = ({ + label, + onClick, + ['data-test-subj']: dataTestSubj, + isDisabled, +}: { + label: string; + onClick: () => void; + 'data-test-subj'?: string; + isDisabled?: boolean; +}) => ( {label} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx new file mode 100644 index 0000000000000..de04e51ef872c --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiButtonIcon, + EuiDraggable, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + htmlIdGenerator, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DragDropBuckets, NewBucketButton } from '../shared_components/buckets'; +import { TooltipWrapper, useDebouncedValue } from '../../../../shared_components'; +import { FieldSelect } from '../../../dimension_panel/field_select'; +import type { TermsIndexPatternColumn } from './types'; +import type { IndexPattern, IndexPatternPrivateState } from '../../../types'; +import type { OperationSupportMatrix } from '../../../dimension_panel'; + +const generateId = htmlIdGenerator(); +export const MAX_MULTI_FIELDS_SIZE = 3; + +export interface FieldInputsProps { + column: TermsIndexPatternColumn; + indexPattern: IndexPattern; + existingFields: IndexPatternPrivateState['existingFields']; + operationSupportMatrix: Pick; + onChange: (newValues: string[]) => void; +} + +interface WrappedValue { + id: string; + value: string | undefined; + isNew?: boolean; +} + +type SafeWrappedValue = Omit & { value: string }; + +function removeNewEmptyField(v: WrappedValue): v is SafeWrappedValue { + return v.value != null; +} + +export function FieldInputs({ + column, + onChange, + indexPattern, + existingFields, + operationSupportMatrix, +}: FieldInputsProps) { + const onChangeWrapped = useCallback( + (values: WrappedValue[]) => + onChange(values.filter(removeNewEmptyField).map(({ value }) => value)), + [onChange] + ); + const { wrappedValues, rawValuesLookup } = useMemo(() => { + const rawValues = column ? [column.sourceField, ...(column.params?.secondaryFields || [])] : []; + return { + wrappedValues: rawValues.map((value) => ({ id: generateId(), value })), + rawValuesLookup: new Set(rawValues), + }; + }, [column]); + + const { inputValue: localValues, handleInputChange } = useDebouncedValue({ + onChange: onChangeWrapped, + value: wrappedValues, + }); + + const onFieldSelectChange = useCallback( + (choice, index = 0) => { + const fields = [...localValues]; + const newFieldName = indexPattern.getFieldByName(choice.field)?.displayName; + if (newFieldName != null) { + fields[index] = { id: generateId(), value: newFieldName }; + + // update the layer state + handleInputChange(fields); + } + }, + [localValues, indexPattern, handleInputChange] + ); + + // diminish attention to adding fields alternative + if (localValues.length === 1) { + const [{ value }] = localValues; + return ( + <> + + { + handleInputChange([ + ...localValues, + { id: generateId(), value: undefined, isNew: true }, + ]); + }} + label={i18n.translate('xpack.lens.indexPattern.terms.addField', { + defaultMessage: 'Add field', + })} + /> + + ); + } + const disableActions = localValues.length === 2 && localValues.some(({ isNew }) => isNew); + const localValuesFilled = localValues.filter(({ isNew }) => !isNew); + return ( + <> + { + handleInputChange(updatedValues); + }} + onDragStart={() => {}} + droppableId="TOP_TERMS_DROPPABLE_AREA" + items={localValues} + > + {localValues.map(({ id, value, isNew }, index) => { + // need to filter the available fields for multiple terms + // * a scripted field should be removed + // * if a field has been used, should it be removed? Probably yes? + // * if a scripted field was used in a singular term, should it be marked as invalid for multi-terms? Probably yes? + const filteredOperationByField = Object.keys(operationSupportMatrix.operationByField) + .filter( + (key) => + (!rawValuesLookup.has(key) && !indexPattern.getFieldByName(key)?.scripted) || + key === value + ) + .reduce((memo, key) => { + memo[key] = operationSupportMatrix.operationByField[key]; + return memo; + }, {}); + + const shouldShowScriptedFieldError = Boolean( + value && indexPattern.getFieldByName(value)?.scripted && localValuesFilled.length > 1 + ); + return ( + + {(provided) => ( + + {/* Empty for spacing */} + + + + + { + onFieldSelectChange(choice, index); + }} + isInvalid={shouldShowScriptedFieldError} + data-test-subj={`indexPattern-dimension-field-${index}`} + /> + + + + { + handleInputChange(localValues.filter((_, i) => i !== index)); + }} + data-test-subj={`indexPattern-terms-removeField-${index}`} + isDisabled={disableActions && !isNew} + /> + + + + )} + + ); + })} + + { + handleInputChange([...localValues, { id: generateId(), value: undefined, isNew: true }]); + }} + data-test-subj={`indexPattern-terms-add-field`} + label={i18n.translate('xpack.lens.indexPattern.terms.addaFilter', { + defaultMessage: 'Add field', + })} + isDisabled={localValues.length > MAX_MULTI_FIELDS_SIZE} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts new file mode 100644 index 0000000000000..4468953a26d17 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts @@ -0,0 +1,474 @@ +/* + * 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 { CoreStart } from 'kibana/public'; +import type { FrameDatasourceAPI } from '../../../../types'; +import type { CountIndexPatternColumn } from '../index'; +import type { TermsIndexPatternColumn } from './types'; +import type { GenericIndexPatternColumn } from '../../../indexpattern'; +import { createMockedIndexPattern } from '../../../mocks'; +import { + getDisallowedTermsMessage, + getMultiTermsScriptedFieldErrorMessage, + isSortableByColumn, + MULTI_KEY_VISUAL_SEPARATOR, +} from './helpers'; +import { ReferenceBasedIndexPatternColumn } from '../column_types'; + +const indexPattern = createMockedIndexPattern(); + +const coreMock = { + uiSettings: { + get: () => undefined, + }, + http: { + post: jest.fn(() => + Promise.resolve({ + topValues: { + buckets: [ + { + key: 'A', + }, + { + key: 'B', + }, + ], + }, + }) + ), + }, +} as unknown as CoreStart; + +function getStringBasedOperationColumn( + field = 'source', + params?: Partial +): TermsIndexPatternColumn { + return { + label: `Top value of ${field}`, + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + ...params, + }, + sourceField: field, + }; +} + +function getLayer( + col1: TermsIndexPatternColumn = getStringBasedOperationColumn(), + cols?: GenericIndexPatternColumn[] +) { + const colsObject = cols + ? cols.reduce((memo, col, i) => ({ ...memo, [`col${i + 2}`]: col }), {}) + : {}; + return { + indexPatternId: '1', + columnOrder: ['col1', ...Object.keys(colsObject)], + columns: { + col1, + ...colsObject, + }, + }; +} + +function getCountOperationColumn( + params?: Partial +): GenericIndexPatternColumn { + return { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + ...params, + }; +} + +describe('getMultiTermsScriptedFieldErrorMessage()', () => { + it('should return no error message for a single field', () => { + expect( + getMultiTermsScriptedFieldErrorMessage(getLayer(), 'col1', indexPattern) + ).toBeUndefined(); + }); + + it('should return no error message for a scripted field when single', () => { + const col = getStringBasedOperationColumn('scripted'); + expect( + getMultiTermsScriptedFieldErrorMessage(getLayer(col), 'col1', indexPattern) + ).toBeUndefined(); + }); + + it('should return an error message for a scripted field when there are multiple fields', () => { + const col = getStringBasedOperationColumn('scripted', { secondaryFields: ['bytes'] }); + expect(getMultiTermsScriptedFieldErrorMessage(getLayer(col), 'col1', indexPattern)).toBe( + 'Scripted fields are not supported when using multiple fields, found scripted' + ); + }); + + it('should return no error message for multiple "native" fields', () => { + const col = getStringBasedOperationColumn('source', { secondaryFields: ['dest'] }); + expect( + getMultiTermsScriptedFieldErrorMessage(getLayer(col), 'col1', indexPattern) + ).toBeUndefined(); + }); + + it('should list all scripted fields in the error message', () => { + const col = getStringBasedOperationColumn('scripted', { + secondaryFields: ['scripted', 'scripted', 'scripted'], + }); + expect(getMultiTermsScriptedFieldErrorMessage(getLayer(col), 'col1', indexPattern)).toBe( + 'Scripted fields are not supported when using multiple fields, found scripted, scripted, scripted, scripted' + ); + }); +}); + +describe('getDisallowedTermsMessage()', () => { + it('should return no error if no shifted dimensions are defined', () => { + expect(getDisallowedTermsMessage(getLayer(), 'col1', indexPattern)).toBeUndefined(); + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [getCountOperationColumn()]), + 'col1', + indexPattern + ) + ).toBeUndefined(); + }); + + it('should return no error for a single dimension shifted', () => { + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [getCountOperationColumn({ timeShift: '1w' })]), + 'col1', + indexPattern + ) + ).toBeUndefined(); + }); + + it('should return no for multiple fields with no shifted dimensions', () => { + expect(getDisallowedTermsMessage(getLayer(), 'col1', indexPattern)).toBeUndefined(); + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [getCountOperationColumn()]), + 'col1', + indexPattern + ) + ).toBeUndefined(); + }); + + it('should return an error for multiple dimensions shifted for a single term', () => { + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + ) + ).toEqual( + expect.objectContaining({ + message: + 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', + fixAction: expect.objectContaining({ label: 'Use filters' }), + }) + ); + }); + + it('should return an error for multiple dimensions shifted for multiple terms', () => { + expect( + getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn('source', { secondaryFields: ['bytes'] }), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + ) + ).toEqual( + expect.objectContaining({ + message: + 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', + fixAction: expect.objectContaining({ label: 'Use filters' }), + }) + ); + }); + + it('should propose a fixAction for single term when no data is available', async () => { + const fixAction = getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + )!.fixAction.newState; + const newLayer = await fixAction( + coreMock, + { + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + } as unknown as FrameDatasourceAPI, + 'first' + ); + + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { + input: { + language: 'kuery', + query: 'source: "A"', + }, + label: 'A', + }, + { + input: { + language: 'kuery', + query: 'source: "B"', + }, + label: 'B', + }, + ], + }, + }) + ); + }); + + it('should propose a fixAction for single term when data is available with current top values', async () => { + const fixAction = getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn(), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + )!.fixAction.newState; + const newLayer = await fixAction( + coreMock, + { + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + activeData: { + first: { + columns: [{ id: 'col1', meta: { field: 'source' } }], + rows: [{ col1: 'myTerm' }, { col1: 'myOtherTerm' }], + }, + }, + } as unknown as FrameDatasourceAPI, + 'first' + ); + + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { input: { language: 'kuery', query: 'source: "myTerm"' }, label: 'myTerm' }, + { input: { language: 'kuery', query: 'source: "myOtherTerm"' }, label: 'myOtherTerm' }, + ], + }, + }) + ); + }); + + it('should propose a fixAction for multiple term when no data is available', async () => { + const fixAction = getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn('source', { secondaryFields: ['bytes'] }), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + )!.fixAction.newState; + const newLayer = await fixAction( + coreMock, + { + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + } as unknown as FrameDatasourceAPI, + 'first' + ); + + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { + input: { + language: 'kuery', + query: 'source: * AND bytes: *', + }, + label: `source: * ${MULTI_KEY_VISUAL_SEPARATOR} bytes: *`, + }, + ], + }, + }) + ); + }); + + it('should propose a fixAction for multiple term when data is available with current top values', async () => { + const fixAction = getDisallowedTermsMessage( + getLayer(getStringBasedOperationColumn('source', { secondaryFields: ['bytes'] }), [ + getCountOperationColumn(), + getCountOperationColumn({ timeShift: '1w' }), + ]), + 'col1', + indexPattern + )!.fixAction.newState; + const newLayer = await fixAction( + coreMock, + { + query: { language: 'kuery', query: 'a: b' }, + filters: [], + dateRange: { + fromDate: '2020', + toDate: '2021', + }, + activeData: { + first: { + columns: [{ id: 'col1', meta: { field: undefined } }], + rows: [ + { col1: { keys: ['myTerm', '4000'] } }, + { col1: { keys: ['myOtherTerm', '8000'] } }, + ], + }, + }, + } as unknown as FrameDatasourceAPI, + 'first' + ); + + expect(newLayer.columns.col1).toEqual( + expect.objectContaining({ + operationType: 'filters', + params: { + filters: [ + { + input: { language: 'kuery', query: 'source: "myTerm" AND bytes: "4000"' }, + label: `source: myTerm ${MULTI_KEY_VISUAL_SEPARATOR} bytes: 4000`, + }, + { + input: { language: 'kuery', query: 'source: "myOtherTerm" AND bytes: "8000"' }, + label: `source: myOtherTerm ${MULTI_KEY_VISUAL_SEPARATOR} bytes: 8000`, + }, + ], + }, + }) + ); + }); +}); + +describe('isSortableByColumn()', () => { + it('should sort by the given column', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [getCountOperationColumn()]), + 'col2' + ) + ).toBeTruthy(); + }); + + it('should not be sortable by full-reference columns', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: `Difference of Average of bytes`, + dataType: 'number', + operationType: 'differences', + isBucketed: false, + references: ['colX'], + scale: 'ratio', + }, + ]), + 'col2' + ) + ).toBeFalsy(); + }); + + it('should not be sortable by referenced columns', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: `Difference of Average of bytes`, + dataType: 'number', + operationType: 'differences', + isBucketed: false, + references: ['col3'], + scale: 'ratio', + }, + { + label: 'Average', + dataType: 'number', + isBucketed: false, + sourceField: 'bytes', + operationType: 'average', + }, + ]), + 'col3' + ) + ).toBeFalsy(); + }); + + it('should not be sortable by a managed column', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: 'Static value: 100', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: 100 }, + references: [], + } as ReferenceBasedIndexPatternColumn, + ]), + 'col2' + ) + ).toBeFalsy(); + }); + + it('should not be sortable by a last_value function', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: 'Last Value', + dataType: 'number', + isBucketed: false, + sourceField: 'bytes', + operationType: 'last_value', + params: { + sortField: 'time', + }, + } as GenericIndexPatternColumn, + ]), + 'col2' + ) + ).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts new file mode 100644 index 0000000000000..2917abbf848f8 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts @@ -0,0 +1,215 @@ +/* + * 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 { uniq } from 'lodash'; +import type { CoreStart } from 'kibana/public'; +import { buildEsQuery } from '@kbn/es-query'; +import { getEsQueryConfig } from '../../../../../../../../src/plugins/data/public'; +import { operationDefinitionMap } from '../index'; +import { defaultLabel } from '../filters'; +import { isReferenced } from '../../layer_helpers'; + +import type { FieldStatsResponse } from '../../../../../common'; +import type { FrameDatasourceAPI } from '../../../../types'; +import type { FiltersIndexPatternColumn } from '../index'; +import type { TermsIndexPatternColumn } from './types'; +import type { IndexPatternLayer, IndexPattern } from '../../../types'; + +export const MULTI_KEY_VISUAL_SEPARATOR = '›'; + +const fullSeparatorString = ` ${MULTI_KEY_VISUAL_SEPARATOR} `; + +export function getMultiTermsScriptedFieldErrorMessage( + layer: IndexPatternLayer, + columnId: string, + indexPattern: IndexPattern +) { + const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn; + const usedFields = [currentColumn.sourceField, ...(currentColumn.params.secondaryFields ?? [])]; + + const scriptedFields = usedFields.filter((field) => indexPattern.getFieldByName(field)?.scripted); + if (usedFields.length < 2 || !scriptedFields.length) { + return; + } + + return i18n.translate('xpack.lens.indexPattern.termsWithMultipleTermsAndScriptedFields', { + defaultMessage: 'Scripted fields are not supported when using multiple fields, found {fields}', + values: { + fields: scriptedFields.join(', '), + }, + }); +} + +function getQueryForMultiTerms(fieldNames: string[], term: string) { + const terms = term.split(fullSeparatorString); + return fieldNames + .map((fieldName, i) => `${fieldName}: ${terms[i] !== '*' ? `"${terms[i]}"` : terms[i]}`) + .join(' AND '); +} + +function getQueryLabel(fieldNames: string[], term: string) { + if (fieldNames.length === 1) { + return term; + } + return term + .split(fullSeparatorString) + .map((t: string, index: number) => { + if (t == null) { + return i18n.translate('xpack.lens.indexPattern.filterBy.emptyFilterQuery', { + defaultMessage: '(empty)', + }); + } + return `${fieldNames[index]}: ${t}`; + }) + .join(fullSeparatorString); +} + +interface MultiFieldKeyFormat { + keys: string[]; +} + +function isMultiFieldValue(term: unknown): term is MultiFieldKeyFormat { + return ( + typeof term === 'object' && + term != null && + 'keys' in term && + Array.isArray((term as MultiFieldKeyFormat).keys) + ); +} + +export function getDisallowedTermsMessage( + layer: IndexPatternLayer, + columnId: string, + indexPattern: IndexPattern +) { + const hasMultipleShifts = + uniq( + Object.values(layer.columns) + .filter((col) => operationDefinitionMap[col.operationType].shiftable) + .map((col) => col.timeShift || '') + ).length > 1; + if (!hasMultipleShifts) { + return undefined; + } + return { + message: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShifts', { + defaultMessage: + 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', + }), + fixAction: { + label: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel', { + defaultMessage: 'Use filters', + }), + newState: async (core: CoreStart, frame: FrameDatasourceAPI, layerId: string) => { + const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn; + const fieldNames = [ + currentColumn.sourceField, + ...(currentColumn.params?.secondaryFields ?? []), + ]; + const activeDataFieldNameMatch = + frame.activeData?.[layerId].columns.find(({ id }) => id === columnId)?.meta.field === + fieldNames[0]; + + let currentTerms = uniq( + frame.activeData?.[layerId].rows + .map((row) => row[columnId] as string | MultiFieldKeyFormat) + .filter((term) => + fieldNames.length > 1 + ? isMultiFieldValue(term) && term.keys[0] !== '__other__' + : typeof term === 'string' && term !== '__other__' + ) + .map((term: string | MultiFieldKeyFormat) => + isMultiFieldValue(term) ? term.keys.join(fullSeparatorString) : term + ) || [] + ); + if (!activeDataFieldNameMatch || currentTerms.length === 0) { + if (fieldNames.length === 1) { + const response: FieldStatsResponse = await core.http.post( + `/api/lens/index_stats/${indexPattern.id}/field`, + { + body: JSON.stringify({ + fieldName: fieldNames[0], + dslQuery: buildEsQuery( + indexPattern, + frame.query, + frame.filters, + getEsQueryConfig(core.uiSettings) + ), + fromDate: frame.dateRange.fromDate, + toDate: frame.dateRange.toDate, + size: currentColumn.params.size, + }), + } + ); + currentTerms = response.topValues?.buckets.map(({ key }) => String(key)) || []; + } + } + // when multi terms the meta.field will always be undefined, so limit the check to no data + if (fieldNames.length > 1 && currentTerms.length === 0) { + // this will produce a query like `field1: * AND field2: * ...etc` + // which is the best we can do for multiple terms when no data is available + currentTerms = [Array(fieldNames.length).fill('*').join(fullSeparatorString)]; + } + + return { + ...layer, + columns: { + ...layer.columns, + [columnId]: { + label: i18n.translate('xpack.lens.indexPattern.pinnedTopValuesLabel', { + defaultMessage: 'Filters of {field}', + values: { + field: + fieldNames.length > 1 ? fieldNames.join(fullSeparatorString) : fieldNames[0], + }, + }), + customLabel: true, + isBucketed: layer.columns[columnId].isBucketed, + dataType: 'string', + operationType: 'filters', + params: { + filters: + currentTerms.length > 0 + ? currentTerms.map((term) => ({ + input: { + query: + fieldNames.length === 1 + ? `${fieldNames[0]}: "${term}"` + : getQueryForMultiTerms(fieldNames, term), + language: 'kuery', + }, + label: getQueryLabel(fieldNames, term), + })) + : [ + { + input: { + query: '*', + language: 'kuery', + }, + label: defaultLabel, + }, + ], + }, + } as FiltersIndexPatternColumn, + }, + }; + }, + }, + }; +} + +export function isSortableByColumn(layer: IndexPatternLayer, columnId: string) { + const column = layer.columns[columnId]; + return ( + column && + !column.isBucketed && + column.operationType !== 'last_value' && + !('references' in column) && + !isReferenced(layer, columnId) + ); +} 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 78894274db168..f84664ccc32d8 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -16,168 +16,53 @@ import { EuiAccordion, EuiIconTip, } from '@elastic/eui'; -import { uniq } from 'lodash'; -import { CoreStart } from 'kibana/public'; -import { buildEsQuery } from '@kbn/es-query'; -import { FieldStatsResponse } from '../../../../../common'; -import { - AggFunctionsMapping, - getEsQueryConfig, -} from '../../../../../../../../src/plugins/data/public'; +import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public'; -import { updateColumnParam, isReferenced } from '../../layer_helpers'; -import { DataType, FrameDatasourceAPI } from '../../../../types'; -import { FiltersIndexPatternColumn, OperationDefinition, operationDefinitionMap } from '../index'; +import { updateColumnParam } from '../../layer_helpers'; +import type { DataType } from '../../../../types'; +import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; import { ValuesInput } from './values_input'; import { getInvalidFieldMessage } from '../helpers'; -import type { IndexPatternLayer, IndexPattern } from '../../../types'; -import { defaultLabel } from '../filters'; +import { FieldInputs, MAX_MULTI_FIELDS_SIZE } from './field_inputs'; +import { + FieldInput as FieldInputBase, + getErrorMessage, +} from '../../../dimension_panel/field_input'; +import type { TermsIndexPatternColumn } from './types'; +import { + getDisallowedTermsMessage, + getMultiTermsScriptedFieldErrorMessage, + isSortableByColumn, +} from './helpers'; + +export type { TermsIndexPatternColumn } from './types'; -function ofName(name?: string) { +const missingFieldLabel = i18n.translate('xpack.lens.indexPattern.missingFieldLabel', { + defaultMessage: 'Missing field', +}); + +function ofName(name?: string, count: number = 0) { + if (count) { + return i18n.translate('xpack.lens.indexPattern.multipleTermsOf', { + defaultMessage: 'Top values of {name} + {count} {count, plural, one {other} other {others}}', + values: { + name: name ?? missingFieldLabel, + count, + }, + }); + } return i18n.translate('xpack.lens.indexPattern.termsOf', { defaultMessage: 'Top values of {name}', values: { - name: - name ?? - i18n.translate('xpack.lens.indexPattern.missingFieldLabel', { - defaultMessage: 'Missing field', - }), + name: name ?? missingFieldLabel, }, }); } -function isSortableByColumn(layer: IndexPatternLayer, columnId: string) { - const column = layer.columns[columnId]; - return ( - column && - !column.isBucketed && - column.operationType !== 'last_value' && - !('references' in column) && - !isReferenced(layer, columnId) - ); -} - -function getDisallowedTermsMessage( - layer: IndexPatternLayer, - columnId: string, - indexPattern: IndexPattern -) { - const hasMultipleShifts = - uniq( - Object.values(layer.columns) - .filter((col) => operationDefinitionMap[col.operationType].shiftable) - .map((col) => col.timeShift || '') - ).length > 1; - if (!hasMultipleShifts) { - return undefined; - } - return { - message: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShifts', { - defaultMessage: - 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.', - }), - fixAction: { - label: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel', { - defaultMessage: 'Use filters', - }), - newState: async (core: CoreStart, frame: FrameDatasourceAPI, layerId: string) => { - const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn; - const fieldName = currentColumn.sourceField; - const activeDataFieldNameMatch = - frame.activeData?.[layerId].columns.find(({ id }) => id === columnId)?.meta.field === - fieldName; - let currentTerms = uniq( - frame.activeData?.[layerId].rows - .map((row) => row[columnId] as string) - .filter((term) => typeof term === 'string' && term !== '__other__') || [] - ); - if (!activeDataFieldNameMatch || currentTerms.length === 0) { - const response: FieldStatsResponse = await core.http.post( - `/api/lens/index_stats/${indexPattern.id}/field`, - { - body: JSON.stringify({ - fieldName, - dslQuery: buildEsQuery( - indexPattern, - frame.query, - frame.filters, - getEsQueryConfig(core.uiSettings) - ), - fromDate: frame.dateRange.fromDate, - toDate: frame.dateRange.toDate, - size: currentColumn.params.size, - }), - } - ); - currentTerms = response.topValues?.buckets.map(({ key }) => String(key)) || []; - } - return { - ...layer, - columns: { - ...layer.columns, - [columnId]: { - label: i18n.translate('xpack.lens.indexPattern.pinnedTopValuesLabel', { - defaultMessage: 'Filters of {field}', - values: { - field: fieldName, - }, - }), - customLabel: true, - isBucketed: layer.columns[columnId].isBucketed, - dataType: 'string', - operationType: 'filters', - params: { - filters: - currentTerms.length > 0 - ? currentTerms.map((term) => ({ - input: { - query: `${fieldName}: "${term}"`, - language: 'kuery', - }, - label: term, - })) - : [ - { - input: { - query: '*', - language: 'kuery', - }, - label: defaultLabel, - }, - ], - }, - } as FiltersIndexPatternColumn, - }, - }; - }, - }, - }; -} - const DEFAULT_SIZE = 3; const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']); -export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { - operationType: 'terms'; - params: { - size: number; - // if order is alphabetical, the `fallback` flag indicates whether it became alphabetical because there wasn't - // another option or whether the user explicitly chose to make it alphabetical. - orderBy: { type: 'alphabetical'; fallback?: boolean } | { type: 'column'; columnId: string }; - orderDirection: 'asc' | 'desc'; - otherBucket?: boolean; - missingBucket?: boolean; - // Terms on numeric fields can be formatted - format?: { - id: string; - params?: { - decimals: number; - }; - }; - }; -} - export const termsOperation: OperationDefinition = { type: 'terms', displayName: i18n.translate('xpack.lens.indexPattern.terms', { @@ -185,6 +70,12 @@ export const termsOperation: OperationDefinition { + return (column.params?.secondaryFields?.length ?? 0) < MAX_MULTI_FIELDS_SIZE; + }, + getDefaultVisualSettings: (column) => ({ + truncateText: Boolean(!column.params?.secondaryFields?.length), + }), getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( supportedTypes.has(type) && @@ -201,6 +92,7 @@ export const termsOperation: OperationDefinition { + if (column.params?.secondaryFields?.length) { + return buildExpressionFunction('aggMultiTerms', { + id: columnId, + enabled: true, + schema: 'segment', + fields: [column.sourceField, ...column.params.secondaryFields], + orderBy: + column.params.orderBy.type === 'alphabetical' + ? '_key' + : String(orderedColumnIds.indexOf(column.params.orderBy.columnId)), + order: column.params.orderDirection, + size: column.params.size, + otherBucket: Boolean(column.params.otherBucket), + otherBucketLabel: i18n.translate('xpack.lens.indexPattern.terms.otherLabel', { + defaultMessage: 'Other', + }), + }).toAst(); + } return buildExpressionFunction('aggTerms', { id: columnId, enabled: true, @@ -268,9 +178,13 @@ export const termsOperation: OperationDefinition - ofName(indexPattern.getFieldByName(column.sourceField)?.displayName), + ofName( + indexPattern.getFieldByName(column.sourceField)?.displayName, + column.params.secondaryFields?.length + ), onFieldChange: (oldColumn, field) => { - const newParams = { ...oldColumn.params }; + // reset the secondary fields + const newParams = { ...oldColumn.params, secondaryFields: undefined }; if ('format' in newParams && field.type !== 'number') { delete newParams.format; } @@ -314,6 +228,89 @@ export const termsOperation: OperationDefinition { + const column = layer.columns[columnId] as TermsIndexPatternColumn; + updateLayer({ + ...layer, + columns: { + ...layer.columns, + [columnId]: { + ...column, + sourceField: fields[0], + label: ofName(indexPattern.getFieldByName(fields[0])?.displayName, fields.length - 1), + params: { + ...column.params, + secondaryFields: fields.length > 1 ? fields.slice(1) : undefined, + }, + }, + } as Record, + }); + }, + [columnId, indexPattern, layer, updateLayer] + ); + const currentColumn = layer.columns[columnId]; + + const fieldErrorMessage = getErrorMessage( + selectedColumn, + Boolean(props.incompleteOperation), + 'field', + props.currentFieldIsInvalid + ); + + // let the default component do its job in case of incomplete informations + if ( + !currentColumn || + !selectedColumn || + props.incompleteOperation || + (fieldErrorMessage && !selectedColumn.params?.secondaryFields?.length) + ) { + return ; + } + + const showScriptedFieldError = Boolean( + getMultiTermsScriptedFieldErrorMessage(layer, columnId, indexPattern) + ); + + return ( + + + + ); + }, paramEditor: function ParamEditor({ layer, updateLayer, currentColumn, columnId, indexPattern }) { const hasRestrictions = indexPattern.hasRestrictions; @@ -350,6 +347,7 @@ export const termsOperation: OperationDefinition { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + htmlIdGenerator: (fn: unknown) => { + let counter = 0; + return () => counter++; + }, + }; +}); const uiSettingsMock = {} as IUiSettingsClient; @@ -45,6 +59,7 @@ const defaultProps = { describe('terms', () => { let layer: IndexPatternLayer; const InlineOptions = termsOperation.paramEditor!; + const InlineFieldInput = termsOperation.renderFieldInput!; beforeEach(() => { layer = { @@ -173,6 +188,30 @@ describe('terms', () => { expect(column).toHaveProperty('sourceField', 'source'); expect(column.params.format).toBeUndefined(); }); + + it('should remove secondary fields when a new field is passed', () => { + const oldColumn: TermsIndexPatternColumn = { + operationType: 'terms', + sourceField: 'bytes', + label: 'Top values of bytes', + isBucketed: true, + dataType: 'number', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + format: { id: 'number', params: { decimals: 0 } }, + secondaryFields: ['dest'], + }, + }; + const indexPattern = createMockedIndexPattern(); + const newStringField = indexPattern.fields.find((i) => i.name === 'source')!; + + const column = termsOperation.onFieldChange(oldColumn, newStringField); + expect(column.params.secondaryFields).toBeUndefined(); + }); }); describe('getPossibleOperationForField', () => { @@ -686,6 +725,575 @@ describe('terms', () => { }); }); + describe('getDefaultLabel', () => { + it('should return the default label for single value', () => { + expect( + termsOperation.getDefaultLabel( + { + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical', fallback: true }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'source', + } as TermsIndexPatternColumn, + createMockedIndexPattern(), + {} + ) + ).toBe('Top values of source'); + }); + + it('should return main value with single counter for two fields', () => { + expect( + termsOperation.getDefaultLabel( + { + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical', fallback: true }, + size: 3, + orderDirection: 'asc', + secondaryFields: ['bytes'], + }, + sourceField: 'source', + } as TermsIndexPatternColumn, + createMockedIndexPattern(), + {} + ) + ).toBe('Top values of source + 1 other'); + }); + + it('should return main value with counter value for multiple values', () => { + expect( + termsOperation.getDefaultLabel( + { + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical', fallback: true }, + size: 3, + orderDirection: 'asc', + secondaryFields: ['bytes', 'memory'], + }, + sourceField: 'source', + } as TermsIndexPatternColumn, + createMockedIndexPattern(), + {} + ) + ).toBe('Top values of source + 2 others'); + }); + }); + + describe('field input', () => { + // @ts-expect-error + window['__react-beautiful-dnd-disable-dev-warnings'] = true; // issue with enzyme & react-beautiful-dnd throwing errors: https://github.com/atlassian/react-beautiful-dnd/issues/1593 + + const defaultFieldInputProps = { + indexPattern: defaultProps.indexPattern, + currentFieldIsInvalid: false, + incompleteField: null, + incompleteOperation: undefined, + incompleteParams: {}, + dimensionGroups: [], + groupId: 'any', + operationDefinitionMap: { terms: termsOperation } as unknown as Record< + string, + GenericOperationDefinition + >, + }; + + function getExistingFields() { + const fields: Record = {}; + for (const field of defaultProps.indexPattern.fields) { + fields[field.name] = true; + } + return { + [layer.indexPatternId]: fields, + }; + } + + function getDefaultOperationSupportMatrix( + columnId: string, + existingFields: Record> + ) { + return getOperationSupportMatrix({ + state: { + layers: { layer1: layer }, + indexPatterns: { + [defaultProps.indexPattern.id]: defaultProps.indexPattern, + }, + existingFields, + } as unknown as IndexPatternPrivateState, + layerId: 'layer1', + filterOperations: () => true, + columnId, + }); + } + + it('should render the default field input for no field (incomplete operation)', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const instance = mount( + + ); + + // Fallback field input has no add button + expect(instance.find('[data-test-subj="indexPattern-terms-add-field"]').exists()).toBeFalsy(); + // check the error state too + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeTruthy(); + }); + + it('should show an error message when field is invalid', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + layer.columns.col1 = { + label: 'Top value of unsupported', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'unsupported', + } as TermsIndexPatternColumn; + const instance = mount( + + ); + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('Invalid field. Check your data view or pick another field.'); + }); + + it('should show an error message when field is not supported', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + layer.columns.col1 = { + label: 'Top value of timestamp', + dataType: 'date', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'timestamp', + } as TermsIndexPatternColumn; + const instance = mount( + + ); + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('This field does not work with the selected function.'); + }); + + it('should render the an add button for single layer, but no other hints', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').exists() + ).toBeTruthy(); + + expect(instance.find('[data-test-subj^="indexPattern-terms-removeField-"]').length).toBe(0); + }); + + it('should render the multi terms specific UI', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['bytes']; + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').exists() + ).toBeTruthy(); + // the produced Enzyme DOM has the both the React component and the actual html + // tags with the same "data-test-subj" assigned. Here it is enough to check that multiple are rendered + expect( + instance.find('[data-test-subj^="indexPattern-terms-removeField-"]').length + ).toBeGreaterThan(1); + expect( + instance.find('[data-test-subj^="indexPattern-terms-dragToReorder-"]').length + ).toBeGreaterThan(1); + }); + + it('should return to single value UI when removing second item of two', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj^="indexPattern-terms-removeField-"]').length + ).toBeGreaterThan(1); + + act(() => { + instance + .find('[data-test-subj="indexPattern-terms-removeField-1"]') + .first() + .simulate('click'); + }); + + expect(instance.find('[data-test-subj="indexPattern-terms-removeField-"]').length).toBe(0); + }); + + it('should disable remove button and reorder drag when single value and one temporary new field', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + let instance = mount( + + ); + + // add a new field + act(() => { + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().simulate('click'); + }); + + instance = instance.update(); + // now two delete buttons should be visualized + expect(instance.find('[data-test-subj="indexPattern-terms-removeField-1"]').exists()).toBe( + true + ); + // first button is disabled + expect( + instance + .find('[data-test-subj="indexPattern-terms-removeField-0"]') + .first() + .prop('isDisabled') + ).toBe(true); + // while second delete is still enabled + expect( + instance + .find('[data-test-subj="indexPattern-terms-removeField-1"]') + .first() + .prop('isDisabled') + ).toBe(false); + }); + + it('should accept scripted fields for single value', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).sourceField = 'scripted'; + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeFalsy(); + }); + + it('should mark scripted fields for multiple values', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).sourceField = 'scripted'; + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; + const instance = mount( + + ); + + expect( + instance + .find('[data-test-subj="indexPattern-field-selection-row"]') + .first() + .prop('isInvalid') + ).toBeTruthy(); + expect( + instance.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBe('Scripted fields are not supported when using multiple fields'); + }); + + it('should not filter scripted fields when in single value', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + const instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-dimension-field"]').first().prop('options') + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ 'data-test-subj': 'lns-fieldOption-scripted' }), + ]), + }), + ]) + ); + }); + + it('should filter scripted fields when in multi terms mode', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; + const instance = mount( + + ); + + // get inner instance + expect( + instance.find('[data-test-subj="indexPattern-dimension-field-0"]').at(1).prop('options') + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + options: expect.arrayContaining([ + expect.not.objectContaining({ 'data-test-subj': 'lns-fieldOption-scripted' }), + ]), + }), + ]) + ); + }); + + it('should filter already used fields when displaying fields list', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory', 'bytes']; + let instance = mount( + + ); + + // add a new field + act(() => { + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().simulate('click'); + }); + + instance = instance.update(); + + // Get the inner instance with the data-test-subj + expect( + instance.find('[data-test-subj="indexPattern-dimension-field-3"]').at(1).prop('options') + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + options: expect.not.arrayContaining([ + expect.objectContaining({ label: 'memory' }), + expect.objectContaining({ label: 'bytes' }), + ]), + }), + ]) + ); + }); + + it('should limit the number of multiple fields', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = [ + 'memory', + 'bytes', + 'dest', + ]; + let instance = mount( + + ); + + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().prop('isDisabled') + ).toBeTruthy(); + // clicking again will no increase the number of fields + act(() => { + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().simulate('click'); + }); + instance = instance.update(); + expect( + instance.find('[data-test-subj="indexPattern-terms-removeField-4"]').exists() + ).toBeFalsy(); + }); + + it('should let the user add new empty field up to the limit', () => { + const updateLayerSpy = jest.fn(); + const existingFields = getExistingFields(); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + + let instance = mount( + + ); + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().prop('isDisabled') + ).toBeFalsy(); + + // click 3 times to add new fields + for (const _ of [1, 2, 3]) { + act(() => { + instance + .find('[data-test-subj="indexPattern-terms-add-field"]') + .first() + .simulate('click'); + }); + instance = instance.update(); + } + + expect( + instance.find('[data-test-subj="indexPattern-terms-add-field"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + }); + describe('param editor', () => { it('should render current other bucket value', () => { const updateLayerSpy = jest.fn(); @@ -1043,6 +1651,39 @@ describe('terms', () => { ]); }); + it('return no error for scripted field when in single mode', () => { + layer = { + ...layer, + columns: { + col1: { + ...layer.columns.col1, + sourceField: 'scripted', + } as TermsIndexPatternColumn, + }, + }; + expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toBeUndefined(); + }); + + it('return error for scripted field when in multi terms mode', () => { + const column = layer.columns.col1 as TermsIndexPatternColumn; + layer = { + ...layer, + columns: { + col1: { + ...column, + sourceField: 'scripted', + params: { + ...column.params, + secondaryFields: ['bytes'], + }, + } as TermsIndexPatternColumn, + }, + }; + expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([ + 'Scripted fields are not supported when using multiple fields, found scripted', + ]); + }); + describe('time shift error', () => { beforeEach(() => { layer = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts new file mode 100644 index 0000000000000..a1b61880ade3f --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts @@ -0,0 +1,36 @@ +/* + * 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 { FieldBasedIndexPatternColumn } from '../column_types'; + +export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'terms'; + params: { + size: number; + // if order is alphabetical, the `fallback` flag indicates whether it became alphabetical because there wasn't + // another option or whether the user explicitly chose to make it alphabetical. + orderBy: { type: 'alphabetical'; fallback?: boolean } | { type: 'column'; columnId: string }; + orderDirection: 'asc' | 'desc'; + otherBucket?: boolean; + missingBucket?: boolean; + secondaryFields?: string[]; + // Terms on numeric fields can be formatted + format?: { + id: string; + params?: { + decimals: number; + }; + }; + parentFormat?: { + id: string; + params?: { + id?: string; + template?: string; + }; + }; + }; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts new file mode 100644 index 0000000000000..a265b27c1dd68 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts @@ -0,0 +1,43 @@ +/* + * 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 { DataType } from '../types'; +import type { DraggedField } from './types'; +import type { + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './operations/definitions/column_types'; + +/** + * Normalizes the specified operation type. (e.g. document operations + * produce 'number') + */ +export function normalizeOperationDataType(type: DataType) { + if (type === 'histogram') return 'number'; + return type === 'document' ? 'number' : type; +} + +export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function sortByField(columns: C[]) { + return [...columns].sort((column1, column2) => { + if (hasField(column1) && hasField(column2)) { + return column1.sourceField.localeCompare(column2.sourceField); + } + return column1.operationType.localeCompare(column2.operationType); + }); +} + +export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { + return ( + typeof fieldCandidate === 'object' && + fieldCandidate !== null && + ['id', 'field', 'indexPatternId'].every((prop) => prop in fieldCandidate) + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx index 6baba7e19716a..76156b5a57a11 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx @@ -11,54 +11,16 @@ import type { DocLinksStart } from 'kibana/public'; import { EuiLink, EuiTextColor } from '@elastic/eui'; import { DatatableColumn } from 'src/plugins/expressions'; -import type { DataType, FramePublicAPI } from '../types'; -import type { - IndexPattern, - IndexPatternLayer, - DraggedField, - IndexPatternPrivateState, -} from './types'; -import type { - BaseIndexPatternColumn, - FieldBasedIndexPatternColumn, - ReferenceBasedIndexPatternColumn, -} from './operations/definitions/column_types'; +import type { FramePublicAPI } from '../types'; +import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from './types'; +import type { ReferenceBasedIndexPatternColumn } from './operations/definitions/column_types'; import { operationDefinitionMap, GenericIndexPatternColumn } from './operations'; import { getInvalidFieldMessage } from './operations/definitions/helpers'; import { isQueryValid } from './operations/definitions/filters'; import { checkColumnForPrecisionError } from '../../../../../src/plugins/data/common'; - -/** - * Normalizes the specified operation type. (e.g. document operations - * produce 'number') - */ -export function normalizeOperationDataType(type: DataType) { - if (type === 'histogram') return 'number'; - return type === 'document' ? 'number' : type; -} - -export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { - return 'sourceField' in column; -} - -export function sortByField(columns: C[]) { - return [...columns].sort((column1, column2) => { - if (hasField(column1) && hasField(column2)) { - return column1.sourceField.localeCompare(column2.sourceField); - } - return column1.operationType.localeCompare(column2.operationType); - }); -} - -export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { - return ( - typeof fieldCandidate === 'object' && - fieldCandidate !== null && - ['id', 'field', 'indexPatternId'].every((prop) => prop in fieldCandidate) - ); -} +import { hasField } from './pure_utils'; export function isColumnInvalid( layer: IndexPatternLayer, @@ -171,3 +133,20 @@ export function getPrecisionErrorWarningMessages( return warningMessages; } + +export function getVisualDefaultsForLayer(layer: IndexPatternLayer) { + return Object.keys(layer.columns).reduce>>( + (memo, columnId) => { + const column = layer.columns[columnId]; + if (column?.operationType) { + const opDefinition = operationDefinitionMap[column.operationType]; + const params = opDefinition.getDefaultVisualSettings?.(column); + if (params) { + memo[columnId] = params; + } + } + return memo; + }, + {} + ); +} diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index 2614b1d5fdc94..50df6f07cb5dc 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -16,6 +16,7 @@ export function createMockDatasource(id: string): DatasourceMock { datasourceId: id, getTableSpec: jest.fn(() => []), getOperationForColumnId: jest.fn(), + getVisualDefaults: jest.fn(), }; return { diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index 57270337e67a4..f4c951cece3c2 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -12,6 +12,7 @@ import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { shouldShowValuesInLegend } from './render_helpers'; import type { PieVisualizationState } from '../../common/expressions'; +import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; export function toExpression( state: PieVisualizationState, @@ -65,7 +66,10 @@ function expressionHelper( : layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS, ], legendMaxLines: [layer.legendMaxLines ?? 1], - truncateLegend: [layer.truncateLegend ?? true], + truncateLegend: [ + layer.truncateLegend ?? + getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, + ], nestedLegend: [!!layer.nestedLegend], ...(state.palette ? { diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 70ad4d8c07daa..997ebb2e3787f 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -23,6 +23,7 @@ import type { PieVisualizationState, SharedPieLayerState } from '../../common/ex import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components'; import { PalettePicker } from '../shared_components'; +import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; import { shouldShowValuesInLegend } from './render_helpers'; const legendOptions: Array<{ @@ -54,7 +55,7 @@ const legendOptions: Array<{ ]; export function PieToolbar(props: VisualizationToolbarProps) { - const { state, setState } = props; + const { state, setState, frame } = props; const layer = state.layers[0]; const onStateChange = useCallback( @@ -126,6 +127,11 @@ export function PieToolbar(props: VisualizationToolbarProps { + it('should return an object with default values for an empty state', () => { + expect(getDefaultVisualValuesForLayer(undefined, {})).toEqual({ truncateText: true }); + }); + + it('should return true if the layer does not have any default for truncation', () => { + const mockDatasource = createMockDatasource('first'); + expect( + getDefaultVisualValuesForLayer({ layerId: 'first' }, { first: mockDatasource.publicAPIMock }) + ).toEqual({ truncateText: true }); + }); + + it('should prioritize layer settings to default ones ', () => { + const mockDatasource = createMockDatasource('first'); + mockDatasource.publicAPIMock.getVisualDefaults = jest.fn(() => ({ + col1: { truncateText: false }, + })); + expect( + getDefaultVisualValuesForLayer({ layerId: 'first' }, { first: mockDatasource.publicAPIMock }) + ).toEqual({ truncateText: false }); + }); + + it('should give priority to first layer', () => { + const mockDatasource = createMockDatasource('first'); + mockDatasource.publicAPIMock.getVisualDefaults = jest.fn(() => ({ + col1: { truncateText: false }, + col2: { truncateText: true }, + })); + expect( + getDefaultVisualValuesForLayer({ layerId: 'first' }, { first: mockDatasource.publicAPIMock }) + ).toEqual({ truncateText: false }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/datasource_default_values.ts b/x-pack/plugins/lens/public/shared_components/datasource_default_values.ts new file mode 100644 index 0000000000000..250c5614fe093 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/datasource_default_values.ts @@ -0,0 +1,45 @@ +/* + * 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 { DatasourcePublicAPI } from '../types'; + +type VisState = { layers: Array<{ layerId: string }> } | { layerId: string }; + +interface MappedVisualValue { + truncateText: boolean; +} + +function hasSingleLayer(state: VisState): state is Extract { + return 'layerId' in state; +} + +function mergeValues(memo: MappedVisualValue, values: Partial, i: number) { + // first the first entry, overwrite + if (i === 0) { + return { ...memo, ...values }; + } + // after the first give priority to existent value + return { ...values, ...memo }; +} + +export function getDefaultVisualValuesForLayer( + state: VisState | undefined, + datasourceLayers: Record +): MappedVisualValue { + const defaultValues = { truncateText: true }; + if (!state) { + return defaultValues; + } + if (hasSingleLayer(state)) { + return Object.values( + datasourceLayers[state.layerId]?.getVisualDefaults() || {} + ).reduce(mergeValues, defaultValues); + } + return state.layers + .flatMap(({ layerId }) => Object.values(datasourceLayers[layerId]?.getVisualDefaults() || {})) + .reduce(mergeValues, defaultValues); +} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 8c5331100e903..eb82bb67c0829 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -303,6 +303,10 @@ export interface DatasourcePublicAPI { datasourceId: string; getTableSpec: () => Array<{ columnId: string }>; getOperationForColumnId: (columnId: string) => Operation | null; + /** + * Collect all default visual values given the current state + */ + getVisualDefaults: () => Record>; } export interface DatasourceDataPanelProps { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 01359c68c6da3..38709ae21d8fd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -497,10 +497,18 @@ export function XYChart({ if (xySeries.seriesKeys.length > 1) { const pointValue = xySeries.seriesKeys[0]; + const splitColumn = table.columns.find(({ id }) => id === layer.splitAccessor); + const splitFormatter = formatFactory(splitColumn && splitColumn.meta?.params); + points.push({ - row: table.rows.findIndex( - (row) => layer.splitAccessor && row[layer.splitAccessor] === pointValue - ), + row: table.rows.findIndex((row) => { + if (layer.splitAccessor) { + if (layersAlreadyFormatted[layer.splitAccessor]) { + return splitFormatter.convert(row[layer.splitAccessor]) === pointValue; + } + return row[layer.splitAccessor] === pointValue; + } + }), column: table.columns.findIndex((col) => col.id === layer.splitAccessor), value: pointValue, }); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 1cd0bab48cd68..22d680caeb12c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -15,6 +15,7 @@ import type { ValidLayer, XYLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; import { hasIcon } from './xy_config_panel/reference_line_panel'; import { defaultReferenceLineColor } from './color_assignment'; +import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { const originalOrder = datasource @@ -173,7 +174,10 @@ export const buildExpression = ( ? [Math.min(5, state.legend.floatingColumns)] : [], maxLines: state.legend.maxLines ? [state.legend.maxLines] : [], - shouldTruncate: [state.legend.shouldTruncate ?? true], + shouldTruncate: [ + state.legend.shouldTruncate ?? + getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, + ], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index 6a43be64ec1d4..3a757c539f08e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -40,6 +40,7 @@ import { getScaleType } from '../to_expression'; import { ColorPicker } from './color_picker'; import { ReferenceLinePanel } from './reference_line_panel'; import { PalettePicker, TooltipWrapper } from '../../shared_components'; +import { getDefaultVisualValuesForLayer } from '../../shared_components/datasource_default_values'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -351,6 +352,12 @@ export const XyToolbar = memo(function XyToolbar( } ); + // Ask the datasource if it has a say about default truncation value + const defaultParamsFromDatasources = getDefaultVisualValuesForLayer( + state, + props.frame.datasourceLayers + ).truncateText; + return ( @@ -421,9 +428,9 @@ export const XyToolbar = memo(function XyToolbar( legend: { ...state.legend, maxLines: val }, }); }} - shouldTruncate={state?.legend.shouldTruncate ?? true} + shouldTruncate={state?.legend.shouldTruncate ?? defaultParamsFromDatasources} onTruncateLegendChange={() => { - const current = state?.legend.shouldTruncate ?? true; + const current = state?.legend.shouldTruncate ?? defaultParamsFromDatasources; setState({ ...state, legend: { ...state.legend, shouldTruncate: !current }, diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index fe0e70a46030f..19bd3510c9527 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -55,6 +55,7 @@ export default function ({ getService, loadTestFile, getPageObjects }: FtrProvid loadTestFile(require.resolve('./table')); loadTestFile(require.resolve('./runtime_fields')); loadTestFile(require.resolve('./dashboard')); + loadTestFile(require.resolve('./multi_terms')); loadTestFile(require.resolve('./epoch_millis')); }); diff --git a/x-pack/test/functional/apps/lens/multi_terms.ts b/x-pack/test/functional/apps/lens/multi_terms.ts new file mode 100644 index 0000000000000..18936f09925f5 --- /dev/null +++ b/x-pack/test/functional/apps/lens/multi_terms.ts @@ -0,0 +1,90 @@ +/* + * 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'); + + describe('lens multi terms suite', () => { + it('should allow creation of lens xy chart with multi terms categories', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await elasticChart.setNewChartUiDebugFlag(true); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.src', + keepOpen: true, + }); + + await PageObjects.lens.addTermToAgg('geo.dest'); + + await PageObjects.lens.closeDimensionEditor(); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0)).to.eql( + 'Top values of geo.src + 1 other' + ); + + await PageObjects.lens.openDimensionEditor('lnsXY_xDimensionPanel'); + + await PageObjects.lens.addTermToAgg('bytes'); + + await PageObjects.lens.closeDimensionEditor(); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0)).to.eql( + 'Top values of geo.src + 2 others' + ); + + const data = await PageObjects.lens.getCurrentChartDebugState(); + expect(data!.bars![0].bars[0].x).to.eql('PE › US › 19,986'); + }); + + it('should allow creation of lens xy chart with multi terms categories split', async () => { + await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.src', + keepOpen: true, + }); + + await PageObjects.lens.addTermToAgg('geo.dest'); + await PageObjects.lens.addTermToAgg('bytes'); + + await PageObjects.lens.closeDimensionEditor(); + + const data = await PageObjects.lens.getCurrentChartDebugState(); + expect(data?.bars?.[0]?.name).to.eql('PE › US › 19,986'); + }); + + it('should not show existing defined fields for new term', async () => { + await PageObjects.lens.openDimensionEditor('lnsXY_splitDimensionPanel'); + + await PageObjects.lens.checkTermsAreNotAvailableToAgg(['bytes', 'geo.src', 'geo.dest']); + + await PageObjects.lens.closeDimensionEditor(); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/time_shift.ts b/x-pack/test/functional/apps/lens/time_shift.ts index 57c2fc194d0c0..6f3cdf751da48 100644 --- a/x-pack/test/functional/apps/lens/time_shift.ts +++ b/x-pack/test/functional/apps/lens/time_shift.ts @@ -64,5 +64,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getDatatableHeaderText(0)).to.eql('Filters of ip'); }); + + it('should show an error if multi terms is used and provide a fix action', async () => { + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_rows > lns-empty-dimension', + operation: 'terms', + field: 'ip', + keepOpen: true, + }); + + await PageObjects.lens.addTermToAgg('geo.src'); + + await PageObjects.lens.closeDimensionEditor(); + + expect(await PageObjects.lens.hasFixAction()).to.be(true); + await PageObjects.lens.useFixAction(); + + expect(await PageObjects.lens.getDatatableHeaderText(1)).to.eql('Filters of ip › geo.src'); + }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 90aba244929e3..3068ad81cfc1a 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -505,6 +505,37 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await PageObjects.common.pressEnterKey(); await PageObjects.common.sleep(1000); // give time for debounced components to rerender }, + + /** + * Add new term to Top values/terms agg + * @param opts field to add + */ + async addTermToAgg(field: string) { + const lastIndex = ( + await find.allByCssSelector('[data-test-subj^="indexPattern-dimension-field"]') + ).length; + await testSubjects.click('indexPattern-terms-add-field'); + // count the number of defined terms + const target = await testSubjects.find(`indexPattern-dimension-field-${lastIndex}`); + await comboBox.openOptionsList(target); + await comboBox.setElement(target, field); + }, + + async checkTermsAreNotAvailableToAgg(fields: string[]) { + const lastIndex = ( + await find.allByCssSelector('[data-test-subj^="indexPattern-dimension-field"]') + ).length; + await testSubjects.click('indexPattern-terms-add-field'); + // count the number of defined terms + const target = await testSubjects.find(`indexPattern-dimension-field-${lastIndex}`); + // await comboBox.openOptionsList(target); + for (const field of fields) { + await comboBox.setCustom(`indexPattern-dimension-field-${lastIndex}`, field); + await comboBox.openOptionsList(target); + await testSubjects.missingOrFail(`lns-fieldOption-${field}`); + } + }, + /** * Save the current Lens visualization. */