[Lens] Introduce 4 new calculation functions: counter rate, cumulative sum, differences, and moving average (#84384)

* [Lens] UI for reference-based functions

* Fix tests

* Add a few unit tests for reference editor

* Respond to review comments

* Update error handling

* Update suggestion logic to work with errors and refs

* Support ParamEditor in references to fix Last Value: refactoring as
needed

* Fix error states

* Update logic for showing references in dimension editor, add tests

* Fix tests

Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Wylie Conlon 2020-12-22 10:33:27 -05:00 committed by GitHub
parent 9bc2fccb2d
commit 23fd044562
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2641 additions and 778 deletions

View file

@ -162,7 +162,7 @@ export function WorkspacePanel({
const expression = useMemo(
() => {
if (!configurationValidationError || configurationValidationError.length === 0) {
if (!configurationValidationError?.length) {
try {
return buildExpression({
visualization: activeVisualization,
@ -400,13 +400,17 @@ export const InnerVisualizationWrapper = ({
showExtraErrors = localState.configurationValidationError
.slice(1)
.map(({ longMessage }) => (
<EuiFlexItem key={longMessage} className="eui-textBreakAll">
<EuiFlexItem
key={longMessage}
className="eui-textBreakAll"
data-test-subj="configuration-failure-error"
>
{longMessage}
</EuiFlexItem>
));
} else {
showExtraErrors = (
<EuiFlexItem data-test-subj="configuration-failure-more-errors">
<EuiFlexItem>
<EuiButtonEmpty
onClick={() => {
setLocalState((prevState: WorkspaceState) => ({
@ -414,6 +418,7 @@ export const InnerVisualizationWrapper = ({
expandError: !prevState.expandError,
}));
}}
data-test-subj="configuration-failure-more-errors"
>
{i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', {
defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`,
@ -445,7 +450,7 @@ export const InnerVisualizationWrapper = ({
</EuiTextColor>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem className="eui-textBreakAll">
<EuiFlexItem className="eui-textBreakAll" data-test-subj="configuration-failure-error">
{localState.configurationValidationError[0].longMessage}
</EuiFlexItem>
{showExtraErrors}

View file

@ -16,6 +16,7 @@ import {
EuiListGroupItemProps,
EuiFormLabel,
EuiToolTip,
EuiText,
} from '@elastic/eui';
import { IndexPatternDimensionEditorProps } from './dimension_panel';
import { OperationSupportMatrix } from './operation_support';
@ -37,6 +38,7 @@ import { BucketNestingEditor } from './bucket_nesting_editor';
import { IndexPattern, IndexPatternLayer } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { FormatSelector } from './format_selector';
import { ReferenceEditor } from './reference_editor';
import { TimeScaling } from './time_scaling';
const operationPanels = getOperationDisplay();
@ -156,7 +158,10 @@ export function DimensionEditor(props: DimensionEditorProps) {
(selectedColumn && !hasField(selectedColumn) && definition.input === 'none'),
disabledStatus:
definition.getDisabledStatus &&
definition.getDisabledStatus(state.indexPatterns[state.currentIndexPatternId]),
definition.getDisabledStatus(
state.indexPatterns[state.currentIndexPatternId],
state.layers[layerId]
),
};
});
@ -180,7 +185,15 @@ export function DimensionEditor(props: DimensionEditorProps) {
}
let label: EuiListGroupItemProps['label'] = operationPanels[operationType].displayName;
if (disabledStatus) {
if (isActive && disabledStatus) {
label = (
<EuiToolTip content={disabledStatus} display="block" position="left">
<EuiText color="danger" size="s">
<strong>{operationPanels[operationType].displayName}</strong>
</EuiText>
</EuiToolTip>
);
} else if (disabledStatus) {
label = (
<EuiToolTip content={disabledStatus} display="block" position="left">
<span>{operationPanels[operationType].displayName}</span>
@ -202,9 +215,12 @@ export function DimensionEditor(props: DimensionEditorProps) {
compatibleWithCurrentField ? '' : ' incompatible'
}`,
onClick() {
if (operationDefinitionMap[operationType].input === 'none') {
if (
operationDefinitionMap[operationType].input === 'none' ||
operationDefinitionMap[operationType].input === 'fullReference'
) {
// Clear invalid state because we are reseting to a valid column
if (selectedColumn?.operationType === operationType) {
// Clear invalid state because we are reseting to a valid column
if (incompleteInfo) {
setStateWrapper(resetIncomplete(state.layers[layerId], columnId));
}
@ -291,6 +307,35 @@ export function DimensionEditor(props: DimensionEditorProps) {
</div>
<EuiSpacer size="s" />
<div className="lnsIndexPatternDimensionEditor__section lnsIndexPatternDimensionEditor__section--shaded">
{!incompleteInfo &&
selectedColumn &&
'references' in selectedColumn &&
selectedOperationDefinition?.input === 'fullReference' ? (
<>
{selectedColumn.references.map((referenceId, index) => {
const validation = selectedOperationDefinition.requiredReferences[index];
return (
<ReferenceEditor
key={index}
layer={state.layers[layerId]}
columnId={referenceId}
updateLayer={(newLayer: IndexPatternLayer) => {
setState(mergeLayer({ state, layerId, newLayer }));
}}
validation={validation}
currentIndexPattern={currentIndexPattern}
existingFields={state.existingFields}
selectionStyle={selectedOperationDefinition.selectionStyle}
dateRange={dateRange}
{...services}
/>
);
})}
<EuiSpacer size="s" />
</>
) : null}
{!selectedColumn ||
selectedOperationDefinition?.input === 'field' ||
(incompleteOperation && operationDefinitionMap[incompleteOperation].input === 'field') ? (
@ -325,7 +370,13 @@ export function DimensionEditor(props: DimensionEditorProps) {
}
incompleteOperation={incompleteOperation}
onDeleteColumn={() => {
setStateWrapper(deleteColumn({ layer: state.layers[layerId], columnId }));
setStateWrapper(
deleteColumn({
layer: state.layers[layerId],
columnId,
indexPattern: currentIndexPattern,
})
);
}}
onChoose={(choice) => {
setStateWrapper(
@ -342,15 +393,6 @@ export function DimensionEditor(props: DimensionEditorProps) {
</EuiFormRow>
) : null}
{!currentFieldIsInvalid && !incompleteInfo && selectedColumn && (
<TimeScaling
selectedColumn={selectedColumn}
columnId={columnId}
layer={state.layers[layerId]}
updateLayer={setStateWrapper}
/>
)}
{!currentFieldIsInvalid && !incompleteInfo && selectedColumn && ParamEditor && (
<>
<ParamEditor
@ -364,6 +406,15 @@ export function DimensionEditor(props: DimensionEditorProps) {
/>
</>
)}
{!currentFieldIsInvalid && !incompleteInfo && selectedColumn && (
<TimeScaling
selectedColumn={selectedColumn}
columnId={columnId}
layer={state.layers[layerId]}
updateLayer={setStateWrapper}
/>
)}
</div>
<EuiSpacer size="s" />
@ -432,11 +483,11 @@ export function DimensionEditor(props: DimensionEditorProps) {
}
function getErrorMessage(
selectedColumn: IndexPatternColumn | undefined,
incompatibleSelectedOperationType: boolean,
incompleteOperation: boolean,
input: 'none' | 'field' | 'fullReference' | undefined,
fieldInvalid: boolean
) {
if (selectedColumn && incompatibleSelectedOperationType) {
if (selectedColumn && incompleteOperation) {
if (input === 'field') {
return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
defaultMessage: 'To use this function, select a different field.',

View file

@ -854,6 +854,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
dataType: 'date',
isBucketed: true,
label: '',
customLabel: true,
operationType: 'date_histogram',
sourceField: 'ts',
params: {
@ -872,6 +873,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
columnId: 'col2',
};
}
it('should not show custom options if time scaling is not available', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
@ -1149,15 +1151,15 @@ describe('IndexPatternDimensionEditorPanel', () => {
layers: {
first: {
...state.layers.first,
columnOrder: ['col1', 'col2'],
columns: {
...state.layers.first.columns,
col2: expect.objectContaining({
sourceField: 'bytes',
operationType: 'avg',
// Other parts of this don't matter for this test
sourceField: 'bytes',
}),
},
columnOrder: ['col1', 'col2'],
incompleteColumns: {},
},
},
},
@ -1237,7 +1239,9 @@ describe('IndexPatternDimensionEditorPanel', () => {
it('should indicate compatible fields when selecting the operation first', () => {
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} columnId={'col2'} />);
wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click');
act(() => {
wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click');
});
const options = wrapper
.find(EuiComboBox)
@ -1317,10 +1321,14 @@ describe('IndexPatternDimensionEditorPanel', () => {
expect(items.map(({ label }: { label: React.ReactNode }) => label)).toEqual([
'Average',
'Count',
'Counter rate',
'Cumulative sum',
'Differences',
'Last value',
'Maximum',
'Median',
'Minimum',
'Moving average',
'Sum',
'Unique count',
]);
@ -1536,4 +1544,101 @@ describe('IndexPatternDimensionEditorPanel', () => {
},
});
});
it('should hide the top level field selector when switching from non-reference to reference', () => {
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} />);
expect(wrapper.find('ReferenceEditor')).toHaveLength(0);
wrapper
.find('button[data-test-subj="lns-indexPatternDimension-derivative incompatible"]')
.simulate('click');
expect(wrapper.find('ReferenceEditor')).toHaveLength(1);
});
it('should hide the reference editors when switching from reference to non-reference', () => {
const stateWithReferences: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Differences of (incomplete)',
dataType: 'number',
isBucketed: false,
operationType: 'derivative',
references: ['col2'],
params: {},
},
});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithReferences} />
);
expect(wrapper.find('ReferenceEditor')).toHaveLength(1);
wrapper
.find('button[data-test-subj="lns-indexPatternDimension-avg incompatible"]')
.simulate('click');
expect(wrapper.find('ReferenceEditor')).toHaveLength(0);
});
it('should show a warning when the current dimension is no longer configurable', () => {
const stateWithInvalidCol: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Invalid derivative',
dataType: 'number',
isBucketed: false,
operationType: 'derivative',
references: ['ref1'],
},
});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithInvalidCol} />
);
expect(
wrapper
.find('[data-test-subj="lns-indexPatternDimension-derivative incompatible"]')
.find('EuiText[color="danger"]')
.first()
).toBeTruthy();
});
it('should remove options to select references when there are no time fields', () => {
const stateWithoutTime: IndexPatternPrivateState = {
...getStateWithColumns({
col1: {
label: 'Avg',
dataType: 'number',
isBucketed: false,
operationType: 'avg',
sourceField: 'bytes',
},
}),
indexPatterns: {
1: {
id: '1',
title: 'my-fake-index-pattern',
hasRestrictions: false,
fields,
getFieldByName: getFieldByNameFactory([
{
name: 'bytes',
displayName: 'bytes',
type: 'number',
aggregatable: true,
searchable: true,
},
]),
},
},
};
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithoutTime} />
);
expect(wrapper.find('[data-test-subj="lns-indexPatternDimension-derivative"]')).toHaveLength(0);
});
});

View file

@ -41,6 +41,7 @@ export interface FieldSelectProps extends EuiComboBoxProps<{}> {
onDeleteColumn: () => void;
existingFields: IndexPatternPrivateState['existingFields'];
fieldIsInvalid: boolean;
markAllFieldsCompatible?: boolean;
}
export function FieldSelect({
@ -53,6 +54,7 @@ export function FieldSelect({
onDeleteColumn,
existingFields,
fieldIsInvalid,
markAllFieldsCompatible,
...rest
}: FieldSelectProps) {
const { operationByField } = operationSupportMatrix;
@ -93,7 +95,7 @@ export function FieldSelect({
: operationByField[field]!.values().next().value,
},
exists: containsData(field),
compatible: isCompatibleWithCurrentOperation(field),
compatible: markAllFieldsCompatible || isCompatibleWithCurrentOperation(field),
};
})
.sort((a, b) => {
@ -163,6 +165,7 @@ export function FieldSelect({
currentIndexPattern,
operationByField,
existingFields,
markAllFieldsCompatible,
]);
return (

View file

@ -49,7 +49,7 @@ export const getOperationSupportMatrix = (props: Props): OperationSupportMatrix
supportedFieldsByOperation[operation.operationType] = new Set();
}
supportedFieldsByOperation[operation.operationType]?.add(operation.field);
} else if (operation.type === 'none') {
} else {
supportedOperationsWithoutField.add(operation.operationType);
}
});

View file

@ -0,0 +1,436 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { 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 type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import type { DataPublicPluginStart } from 'src/plugins/data/public';
import { OperationMetadata } from '../../types';
import { createMockedIndexPattern } from '../mocks';
import { ReferenceEditor, ReferenceEditorProps } from './reference_editor';
import { insertOrReplaceColumn } from '../operations';
import { FieldSelect } from './field_select';
jest.mock('../operations');
describe('reference editor', () => {
let wrapper: ReactWrapper | ShallowWrapper;
let updateLayer: jest.Mock<ReferenceEditorProps['updateLayer']>;
function getDefaultArgs() {
return {
layer: {
indexPatternId: '1',
columns: {},
columnOrder: [],
},
columnId: 'ref',
updateLayer,
selectionStyle: 'full' as const,
currentIndexPattern: createMockedIndexPattern(),
existingFields: {
'my-fake-index-pattern': {
timestamp: true,
bytes: true,
memory: true,
source: true,
},
},
dateRange: { fromDate: 'now-1d', toDate: 'now' },
storage: {} as IStorageWrapper,
uiSettings: {} as IUiSettingsClient,
savedObjectsClient: {} as SavedObjectsClientContract,
http: {} as HttpSetup,
data: {} as DataPublicPluginStart,
};
}
beforeEach(() => {
updateLayer = jest.fn().mockImplementation((newLayer) => {
if (wrapper instanceof ReactWrapper) {
wrapper.setProps({ layer: newLayer });
}
});
jest.clearAllMocks();
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
it('should indicate that all functions and available fields are compatible in the empty state', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number',
}}
/>
);
const functions = wrapper
.find(EuiComboBox)
.filter('[data-test-subj="indexPattern-reference-function"]')
.prop('options');
expect(functions).not.toContainEqual(
expect.objectContaining({ 'data-test-subj': expect.stringContaining('Incompatible') })
);
const fields = wrapper
.find(EuiComboBox)
.filter('[data-test-subj="indexPattern-dimension-field"]')
.prop('options');
expect(fields![0].options).not.toContainEqual(
expect.objectContaining({ 'data-test-subj': expect.stringContaining('Incompatible') })
);
expect(fields![1].options).not.toContainEqual(
expect.objectContaining({ 'data-test-subj': expect.stringContaining('Incompatible') })
);
});
it('should indicate functions and fields that are incompatible with the current', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
layer={{
indexPatternId: '1',
columnOrder: ['ref'],
columns: {
ref: {
label: 'Top values of dest',
dataType: 'string',
isBucketed: true,
operationType: 'terms',
sourceField: 'dest',
params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'desc' },
},
},
}}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => meta.isBucketed,
}}
/>
);
const functions = wrapper
.find(EuiComboBox)
.filter('[data-test-subj="indexPattern-reference-function"]')
.prop('options');
expect(functions.find(({ label }) => label === 'Date histogram')!['data-test-subj']).toContain(
'incompatible'
);
const fields = wrapper
.find(EuiComboBox)
.filter('[data-test-subj="indexPattern-dimension-field"]')
.prop('options');
expect(
fields![0].options!.find(({ label }) => label === 'timestampLabel')!['data-test-subj']
).toContain('Incompatible');
});
it('should not update when selecting the same operation', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
layer={{
indexPatternId: '1',
columnOrder: ['ref'],
columns: {
ref: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
operationType: 'avg',
sourceField: 'bytes',
},
},
}}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number',
}}
/>
);
const comboBox = wrapper
.find(EuiComboBox)
.filter('[data-test-subj="indexPattern-reference-function"]');
const option = comboBox.prop('options')!.find(({ label }) => label === 'Average')!;
act(() => {
comboBox.prop('onChange')!([option]);
});
expect(insertOrReplaceColumn).not.toHaveBeenCalled();
});
it('should keep the field when replacing an existing reference with a compatible function', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
layer={{
indexPatternId: '1',
columnOrder: ['ref'],
columns: {
ref: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
operationType: 'avg',
sourceField: 'bytes',
},
},
}}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number',
}}
/>
);
const comboBox = wrapper
.find(EuiComboBox)
.filter('[data-test-subj="indexPattern-reference-function"]');
const option = comboBox.prop('options')!.find(({ label }) => label === 'Maximum')!;
act(() => {
comboBox.prop('onChange')!([option]);
});
expect(insertOrReplaceColumn).toHaveBeenCalledWith(
expect.objectContaining({
op: 'max',
field: expect.objectContaining({ name: 'bytes' }),
})
);
});
it('should transition to another function with incompatible field', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
layer={{
indexPatternId: '1',
columnOrder: ['ref'],
columns: {
ref: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
operationType: 'avg',
sourceField: 'bytes',
},
},
}}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => true,
}}
/>
);
const comboBox = wrapper
.find(EuiComboBox)
.filter('[data-test-subj="indexPattern-reference-function"]');
const option = comboBox.prop('options')!.find(({ label }) => label === 'Date histogram')!;
act(() => {
comboBox.prop('onChange')!([option]);
});
expect(insertOrReplaceColumn).toHaveBeenCalledWith(
expect.objectContaining({
op: 'date_histogram',
field: undefined,
})
);
});
it('should hide the function selector when using a field-only selection style', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
selectionStyle={'field' as const}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => true,
}}
/>
);
const comboBox = wrapper
.find(EuiComboBox)
.filter('[data-test-subj="indexPattern-reference-function"]');
expect(comboBox).toHaveLength(0);
});
it('should pass the incomplete operation info to FieldSelect', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
layer={{
indexPatternId: '1',
columnOrder: ['ref'],
columns: {
ref: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
operationType: 'avg',
sourceField: 'bytes',
},
},
incompleteColumns: {
ref: { operationType: 'max' },
},
}}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => true,
}}
/>
);
const fieldSelect = wrapper.find(FieldSelect);
expect(fieldSelect.prop('fieldIsInvalid')).toEqual(true);
expect(fieldSelect.prop('selectedField')).toEqual('bytes');
expect(fieldSelect.prop('selectedOperationType')).toEqual('avg');
expect(fieldSelect.prop('incompleteOperation')).toEqual('max');
expect(fieldSelect.prop('markAllFieldsCompatible')).toEqual(false);
});
it('should pass the incomplete field info to FieldSelect', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
layer={{
indexPatternId: '1',
columnOrder: ['ref'],
columns: {
ref: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
operationType: 'avg',
sourceField: 'bytes',
},
},
incompleteColumns: {
ref: { sourceField: 'timestamp' },
},
}}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => true,
}}
/>
);
const fieldSelect = wrapper.find(FieldSelect);
expect(fieldSelect.prop('fieldIsInvalid')).toEqual(false);
expect(fieldSelect.prop('selectedField')).toEqual('timestamp');
expect(fieldSelect.prop('selectedOperationType')).toEqual('avg');
expect(fieldSelect.prop('incompleteOperation')).toBeUndefined();
});
it('should show the FieldSelect as invalid in the empty state for field-only forms', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
selectionStyle="field"
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => true,
}}
/>
);
const fieldSelect = wrapper.find(FieldSelect);
expect(fieldSelect.prop('fieldIsInvalid')).toEqual(true);
expect(fieldSelect.prop('selectedField')).toBeUndefined();
expect(fieldSelect.prop('selectedOperationType')).toBeUndefined();
expect(fieldSelect.prop('incompleteOperation')).toBeUndefined();
expect(fieldSelect.prop('markAllFieldsCompatible')).toEqual(true);
});
it('should show the ParamEditor for functions that offer one', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
layer={{
indexPatternId: '1',
columnOrder: ['ref'],
columns: {
ref: {
label: 'Last value of bytes',
dataType: 'number',
isBucketed: false,
operationType: 'last_value',
sourceField: 'bytes',
params: {
sortField: 'timestamp',
},
},
},
}}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => true,
}}
/>
);
expect(wrapper.find('[data-test-subj="lns-indexPattern-lastValue-sortField"]').exists()).toBe(
true
);
});
it('should hide the ParamEditor for incomplete functions', () => {
wrapper = mount(
<ReferenceEditor
{...getDefaultArgs()}
layer={{
indexPatternId: '1',
columnOrder: ['ref'],
columns: {
ref: {
label: 'Last value of bytes',
dataType: 'number',
isBucketed: false,
operationType: 'last_value',
sourceField: 'bytes',
params: {
sortField: 'timestamp',
},
},
},
incompleteColumns: {
ref: { operationType: 'max' },
},
}}
validation={{
input: ['field'],
validateMetadata: (meta: OperationMetadata) => true,
}}
/>
);
expect(wrapper.find('[data-test-subj="lns-indexPattern-lastValue-sortField"]').exists()).toBe(
false
);
});
});

View file

@ -0,0 +1,306 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './dimension_editor.scss';
import _ from 'lodash';
import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiSpacer, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import type { DataPublicPluginStart } from 'src/plugins/data/public';
import type { DateRange } from '../../../common';
import type { OperationSupportMatrix } from './operation_support';
import type { OperationType } from '../indexpattern';
import {
operationDefinitionMap,
getOperationDisplay,
insertOrReplaceColumn,
deleteColumn,
isOperationAllowedAsReference,
FieldBasedIndexPatternColumn,
RequiredReference,
} from '../operations';
import { FieldSelect } from './field_select';
import { hasField } from '../utils';
import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
const operationPanels = getOperationDisplay();
export interface ReferenceEditorProps {
layer: IndexPatternLayer;
selectionStyle: 'full' | 'field';
validation: RequiredReference;
columnId: string;
updateLayer: (newLayer: IndexPatternLayer) => void;
currentIndexPattern: IndexPattern;
existingFields: IndexPatternPrivateState['existingFields'];
dateRange: DateRange;
// Services
uiSettings: IUiSettingsClient;
storage: IStorageWrapper;
savedObjectsClient: SavedObjectsClientContract;
http: HttpSetup;
data: DataPublicPluginStart;
}
export function ReferenceEditor(props: ReferenceEditorProps) {
const {
layer,
columnId,
updateLayer,
currentIndexPattern,
existingFields,
validation,
selectionStyle,
dateRange,
...services
} = props;
const column = layer.columns[columnId];
const selectedOperationDefinition = column && operationDefinitionMap[column.operationType];
const ParamEditor = selectedOperationDefinition?.paramEditor;
const incompleteInfo = layer.incompleteColumns ? layer.incompleteColumns[columnId] : undefined;
const incompleteOperation = incompleteInfo?.operationType;
const incompleteField = incompleteInfo?.sourceField ?? null;
// Basically the operation support matrix, but different validation
const operationSupportMatrix: OperationSupportMatrix & {
operationTypes: Set<OperationType>;
} = useMemo(() => {
const operationTypes: Set<OperationType> = new Set();
const operationWithoutField: Set<OperationType> = new Set();
const operationByField: Partial<Record<string, Set<OperationType>>> = {};
const fieldByOperation: Partial<Record<OperationType, Set<string>>> = {};
Object.values(operationDefinitionMap)
.sort((op1, op2) => {
return op1.displayName.localeCompare(op2.displayName);
})
.forEach((op) => {
if (op.input === 'field') {
const allFields = currentIndexPattern.fields.filter((field) =>
isOperationAllowedAsReference({
operationType: op.type,
validation,
field,
indexPattern: currentIndexPattern,
})
);
if (allFields.length) {
operationTypes.add(op.type);
fieldByOperation[op.type] = new Set(allFields.map(({ name }) => name));
allFields.forEach((field) => {
if (!operationByField[field.name]) {
operationByField[field.name] = new Set();
}
operationByField[field.name]?.add(op.type);
});
}
} else if (
isOperationAllowedAsReference({
operationType: op.type,
validation,
indexPattern: currentIndexPattern,
})
) {
operationTypes.add(op.type);
operationWithoutField.add(op.type);
}
});
return {
operationTypes,
operationWithoutField,
operationByField,
fieldByOperation,
};
}, [currentIndexPattern, validation]);
const functionOptions: Array<EuiComboBoxOptionOption<OperationType>> = Array.from(
operationSupportMatrix.operationTypes
).map((operationType) => {
const def = operationDefinitionMap[operationType];
const label = operationPanels[operationType].displayName;
const isCompatible =
!column ||
(column &&
hasField(column) &&
def.input === 'field' &&
operationSupportMatrix.fieldByOperation[operationType]?.has(column.sourceField)) ||
(column && !hasField(column) && def.input !== 'field');
return {
label,
value: operationType,
className: 'lnsIndexPatternDimensionEditor__operation',
'data-test-subj': `lns-indexPatternDimension-${operationType}${
isCompatible ? '' : ' incompatible'
}`,
};
});
function onChooseFunction(operationType: OperationType) {
if (column?.operationType === operationType) {
return;
}
const possibleFieldNames = operationSupportMatrix.fieldByOperation[operationType];
if (column && 'sourceField' in column && possibleFieldNames?.has(column.sourceField)) {
// Reuse the current field if possible
updateLayer(
insertOrReplaceColumn({
layer,
columnId,
op: operationType,
indexPattern: currentIndexPattern,
field: currentIndexPattern.getFieldByName(column.sourceField),
})
);
} else {
// If reusing the field is impossible, we generally can't choose for the user.
// The one exception is if the field is the only possible field, like Count of Records.
const possibleField =
possibleFieldNames?.size === 1
? currentIndexPattern.getFieldByName(possibleFieldNames.values().next().value)
: undefined;
updateLayer(
insertOrReplaceColumn({
layer,
columnId,
op: operationType,
indexPattern: currentIndexPattern,
field: possibleField,
})
);
}
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
return;
}
const selectedOption = incompleteInfo?.operationType
? [functionOptions.find(({ value }) => value === incompleteInfo.operationType)!]
: column
? [functionOptions.find(({ value }) => value === column.operationType)!]
: [];
// If the operationType is incomplete, the user needs to select a field- so
// the function is marked as valid.
const showOperationInvalid = !column && !Boolean(incompleteInfo?.operationType);
// The field is invalid if the operation has been updated without a field,
// or if we are in a field-only mode but empty state
const showFieldInvalid =
Boolean(incompleteInfo?.operationType) || (selectionStyle === 'field' && !column);
return (
<div id={columnId}>
<div>
{selectionStyle !== 'field' ? (
<>
<EuiFormRow
data-test-subj="indexPattern-subFunction-selection-row"
label={i18n.translate('xpack.lens.indexPattern.chooseSubFunction', {
defaultMessage: 'Choose a sub-function',
})}
fullWidth
isInvalid={showOperationInvalid}
>
<EuiComboBox
fullWidth
compressed
isClearable={false}
data-test-subj="indexPattern-reference-function"
placeholder={i18n.translate(
'xpack.lens.indexPattern.referenceFunctionPlaceholder',
{
defaultMessage: 'Sub-function',
}
)}
options={functionOptions}
isInvalid={showOperationInvalid}
selectedOptions={selectedOption}
singleSelection={{ asPlainText: true }}
onChange={(choices) => {
if (choices.length === 0) {
updateLayer(
deleteColumn({ layer, columnId, indexPattern: currentIndexPattern })
);
return;
}
trackUiEvent('indexpattern_dimension_field_changed');
onChooseFunction(choices[0].value!);
}}
/>
</EuiFormRow>
<EuiSpacer size="s" />
</>
) : null}
{!column || selectedOperationDefinition.input === 'field' ? (
<EuiFormRow
data-test-subj="indexPattern-reference-field-selection-row"
label={i18n.translate('xpack.lens.indexPattern.chooseField', {
defaultMessage: 'Select a field',
})}
fullWidth
isInvalid={showFieldInvalid}
>
<FieldSelect
fieldIsInvalid={showFieldInvalid}
currentIndexPattern={currentIndexPattern}
existingFields={existingFields}
operationSupportMatrix={operationSupportMatrix}
selectedOperationType={
// Allows operation to be selected before creating a valid column
column ? column.operationType : incompleteOperation
}
selectedField={
// Allows field to be selected
incompleteField
? incompleteField
: (column as FieldBasedIndexPatternColumn)?.sourceField
}
incompleteOperation={incompleteOperation}
markAllFieldsCompatible={selectionStyle === 'field'}
onDeleteColumn={() => {
updateLayer(deleteColumn({ layer, columnId, indexPattern: currentIndexPattern }));
}}
onChoose={(choice) => {
updateLayer(
insertOrReplaceColumn({
layer,
columnId,
indexPattern: currentIndexPattern,
op: choice.operationType,
field: currentIndexPattern.getFieldByName(choice.field),
})
);
}}
/>
</EuiFormRow>
) : null}
{column && !incompleteInfo && ParamEditor && (
<>
<ParamEditor
updateLayer={updateLayer}
currentColumn={column}
layer={layer}
columnId={columnId}
indexPattern={currentIndexPattern}
dateRange={dateRange}
{...services}
/>
</>
)}
</div>
</div>
);
}

View file

@ -858,183 +858,47 @@ describe('IndexPattern Data Source', () => {
it('should return null for non-existant columns', () => {
expect(publicAPI.getOperationForColumnId('col2')).toBe(null);
});
it('should return null for referenced columns', () => {
publicAPI = indexPatternDatasource.getPublicAPI({
state: {
...enrichBaseState(baseState),
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
label: 'Sum',
dataType: 'number',
isBucketed: false,
operationType: 'sum',
sourceField: 'test',
params: {},
} as IndexPatternColumn,
col2: {
label: 'Cumulative sum',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['col1'],
params: {},
} as IndexPatternColumn,
},
},
},
},
layerId: 'first',
});
expect(publicAPI.getOperationForColumnId('col1')).toEqual(null);
});
});
});
describe('#getErrorMessages', () => {
it('should detect a missing reference in a layer', () => {
const state = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'bytes',
},
},
},
},
currentIndexPatternId: '1',
};
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual({
shortMessage: 'Invalid reference.',
longMessage: '"Foo" has an invalid reference.',
});
});
it('should detect and batch missing references in a layer', () => {
const state = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'bytes',
},
col2: {
dataType: 'number',
isBucketed: false,
label: 'Foo2',
operationType: 'count', // <= invalid
sourceField: 'memory',
},
},
},
},
currentIndexPatternId: '1',
};
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual({
shortMessage: 'Invalid references.',
longMessage: '"Foo", "Foo2" have invalid reference.',
});
});
it('should detect and batch missing references in multiple layers', () => {
const state = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'bytes',
},
col2: {
dataType: 'number',
isBucketed: false,
label: 'Foo2',
operationType: 'count', // <= invalid
sourceField: 'memory',
},
},
},
second: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'string',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'source',
},
},
},
},
currentIndexPatternId: '1',
};
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
expect(messages).toHaveLength(2);
expect(messages).toEqual([
{
shortMessage: 'Invalid references on Layer 1.',
longMessage: 'Layer 1 has invalid references in "Foo", "Foo2".',
},
{
shortMessage: 'Invalid reference on Layer 2.',
longMessage: 'Layer 2 has an invalid reference in "Foo".',
},
]);
});
it('should return no errors if all references are satified', () => {
const state = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'avg',
sourceField: 'bytes',
},
},
},
},
currentIndexPatternId: '1',
};
expect(
indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState)
).toBeUndefined();
});
it('should return no errors with layers with no columns', () => {
const state: IndexPatternPrivateState = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
},
currentIndexPatternId: '1',
};
expect(indexPatternDatasource.getErrorMessages(state)).toBeUndefined();
});
it('should bubble up invalid configuration from operations', () => {
it('should use the results of getErrorMessages directly when single layer', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
const state: IndexPatternPrivateState = {
@ -1052,11 +916,40 @@ describe('IndexPattern Data Source', () => {
currentIndexPatternId: '1',
};
expect(indexPatternDatasource.getErrorMessages(state)).toEqual([
{ shortMessage: 'error 1', longMessage: '' },
{ shortMessage: 'error 2', longMessage: '' },
{ longMessage: 'error 1', shortMessage: '' },
{ longMessage: 'error 2', shortMessage: '' },
]);
expect(getErrorMessages).toHaveBeenCalledTimes(1);
});
it('should prepend each error with its layer number on multi-layer chart', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
const state: IndexPatternPrivateState = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
second: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
},
currentIndexPatternId: '1',
};
expect(indexPatternDatasource.getErrorMessages(state)).toEqual([
{ longMessage: 'Layer 1 error: error 1', shortMessage: '' },
{ longMessage: 'Layer 1 error: error 2', shortMessage: '' },
]);
expect(getErrorMessages).toHaveBeenCalledTimes(2);
});
});
describe('#updateStateOnCloseDimension', () => {

View file

@ -39,12 +39,7 @@ import {
getDatasourceSuggestionsForVisualizeField,
} from './indexpattern_suggestions';
import {
getInvalidColumnsForLayer,
getInvalidLayers,
isDraggedField,
normalizeOperationDataType,
} from './utils';
import { isDraggedField, normalizeOperationDataType } from './utils';
import { LayerPanel } from './layerpanel';
import { IndexPatternColumn, getErrorMessages, IncompleteColumn } from './operations';
import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types';
@ -55,7 +50,6 @@ import { mergeLayer } from './state_helpers';
import { Datasource, StateSetter } from '../index';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { deleteColumn, isReferenced } from './operations';
import { FieldBasedIndexPatternColumn } from './operations/definitions/column_types';
import { Dragging } from '../drag_drop/providers';
export { OperationType, IndexPatternColumn, deleteColumn } from './operations';
@ -162,10 +156,11 @@ export function getIndexPatternDatasource({
},
removeColumn({ prevState, layerId, columnId }) {
const indexPattern = prevState.indexPatterns[prevState.layers[layerId]?.indexPatternId];
return mergeLayer({
state: prevState,
layerId,
newLayer: deleteColumn({ layer: prevState.layers[layerId], columnId }),
newLayer: deleteColumn({ layer: prevState.layers[layerId], columnId, indexPattern }),
});
},
@ -351,7 +346,9 @@ export function getIndexPatternDatasource({
const layer = state.layers[layerId];
if (layer && layer.columns[columnId]) {
return columnToOperation(layer.columns[columnId], columnLabelMap[columnId]);
if (!isReferenced(layer, columnId)) {
return columnToOperation(layer.columns[columnId], columnLabelMap[columnId]);
}
}
return null;
},
@ -369,91 +366,46 @@ export function getIndexPatternDatasource({
if (!state) {
return;
}
const invalidLayers = getInvalidLayers(state);
const layerErrors = Object.values(state.layers).flatMap((layer) =>
const layerErrors = Object.values(state.layers).map((layer) =>
(getErrorMessages(layer) ?? []).map((message) => ({
shortMessage: message,
longMessage: '',
shortMessage: '', // Not displayed currently
longMessage: message,
}))
);
if (invalidLayers.length === 0) {
return layerErrors.length ? layerErrors : undefined;
// Single layer case, no need to explain more
if (layerErrors.length <= 1) {
return layerErrors[0]?.length ? layerErrors[0] : undefined;
}
const realIndex = Object.values(state.layers)
.map((layer, i) => {
const filteredIndex = invalidLayers.indexOf(layer);
if (filteredIndex > -1) {
return [filteredIndex, i + 1];
}
})
.filter(Boolean) as Array<[number, number]>;
const invalidColumnsForLayer: string[][] = getInvalidColumnsForLayer(
invalidLayers,
state.indexPatterns
);
const originalLayersList = Object.keys(state.layers);
if (layerErrors.length || realIndex.length) {
return [
...layerErrors,
...realIndex.map(([filteredIndex, layerIndex]) => {
const columnLabelsWithBrokenReferences: string[] = invalidColumnsForLayer[
filteredIndex
].map((columnId) => {
const column = invalidLayers[filteredIndex].columns[
columnId
] as FieldBasedIndexPatternColumn;
return column.label;
});
if (originalLayersList.length === 1) {
return {
shortMessage: i18n.translate(
'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer',
{
defaultMessage:
'Invalid {columns, plural, one {reference} other {references}}.',
values: {
columns: columnLabelsWithBrokenReferences.length,
},
}
),
longMessage: i18n.translate(
'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer',
{
defaultMessage: `"{columns}" {columnsLength, plural, one {has an} other {have}} invalid reference.`,
values: {
columns: columnLabelsWithBrokenReferences.join('", "'),
columnsLength: columnLabelsWithBrokenReferences.length,
},
}
),
};
}
return {
shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', {
defaultMessage:
'Invalid {columnsLength, plural, one {reference} other {references}} on Layer {layer}.',
values: {
layer: layerIndex,
columnsLength: columnLabelsWithBrokenReferences.length,
},
}),
longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', {
defaultMessage: `Layer {layer} has {columnsLength, plural, one {an invalid} other {invalid}} {columnsLength, plural, one {reference} other {references}} in "{columns}".`,
values: {
layer: layerIndex,
columns: columnLabelsWithBrokenReferences.join('", "'),
columnsLength: columnLabelsWithBrokenReferences.length,
},
}),
};
}),
];
}
// For multiple layers we will prepend each error with the layer number
const messages = layerErrors.flatMap((errors, index) => {
return errors.map((error) => {
const { shortMessage, longMessage } = error;
return {
shortMessage: shortMessage
? i18n.translate('xpack.lens.indexPattern.layerErrorWrapper', {
defaultMessage: 'Layer {position} error: {wrappedMessage}',
values: {
position: index + 1,
wrappedMessage: shortMessage,
},
})
: '',
longMessage: longMessage
? i18n.translate('xpack.lens.indexPattern.layerErrorWrapper', {
defaultMessage: 'Layer {position} error: {wrappedMessage}',
values: {
position: index + 1,
wrappedMessage: longMessage,
},
})
: '',
};
});
});
return messages.length ? messages : undefined;
},
};

View file

@ -6,11 +6,12 @@
import { DatasourceSuggestion } from '../types';
import { generateId } from '../id_generator';
import { IndexPatternPrivateState } from './types';
import type { IndexPatternPrivateState } from './types';
import {
getDatasourceSuggestionsForField,
getDatasourceSuggestionsFromCurrentState,
getDatasourceSuggestionsForVisualizeField,
IndexPatternSuggestion,
} from './indexpattern_suggestions';
import { documentField } from './document_field';
import { getFieldByNameFactory } from './pure_helpers';
@ -153,6 +154,7 @@ function testInitialState(): IndexPatternPrivateState {
columns: {
col1: {
label: 'My Op',
customLabel: true,
dataType: 'string',
isBucketed: true,
@ -172,6 +174,19 @@ function testInitialState(): IndexPatternPrivateState {
};
}
// Simplifies the debug output for failed test
function getSuggestionSubset(
suggestions: IndexPatternSuggestion[]
): Array<Omit<IndexPatternSuggestion, 'state'>> {
return suggestions.map((s) => {
const newSuggestion = { ...s } as Omit<IndexPatternSuggestion, 'state'> & {
state?: IndexPatternPrivateState;
};
delete newSuggestion.state;
return newSuggestion;
});
}
describe('IndexPattern Data Source suggestions', () => {
beforeEach(async () => {
let count = 0;
@ -698,6 +713,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
sourceField: 'source',
label: 'values of source',
customLabel: true,
operationType: 'terms',
params: {
orderBy: { type: 'column', columnId: 'colb' },
@ -710,6 +726,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
sourceField: 'bytes',
label: 'Avg of bytes',
customLabel: true,
operationType: 'avg',
},
},
@ -733,7 +750,7 @@ describe('IndexPattern Data Source suggestions', () => {
dataType: 'date',
isBucketed: true,
sourceField: 'timestamp',
label: 'date histogram of timestamp',
label: 'timestamp',
operationType: 'date_histogram',
params: {
interval: 'w',
@ -744,6 +761,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
sourceField: 'bytes',
label: 'Avg of bytes',
customLabel: true,
operationType: 'avg',
},
},
@ -782,6 +800,7 @@ describe('IndexPattern Data Source suggestions', () => {
});
it('puts a date histogram column after the last bucket column on date field', () => {
(generateId as jest.Mock).mockReturnValue('newid');
const initialState = stateWithNonEmptyTables();
const suggestions = getDatasourceSuggestionsForField(initialState, '1', {
name: 'timestamp',
@ -790,17 +809,16 @@ describe('IndexPattern Data Source suggestions', () => {
aggregatable: true,
searchable: true,
});
expect(suggestions).toContainEqual(
expect.objectContaining({
state: expect.objectContaining({
layers: {
previousLayer: initialState.layers.previousLayer,
currentLayer: expect.objectContaining({
columnOrder: ['cola', 'id1', 'colb'],
columnOrder: ['cola', 'newid', 'colb'],
columns: {
...initialState.layers.currentLayer.columns,
id1: expect.objectContaining({
newid: expect.objectContaining({
operationType: 'date_histogram',
sourceField: 'timestamp',
}),
@ -817,7 +835,7 @@ describe('IndexPattern Data Source suggestions', () => {
columnId: 'cola',
}),
expect.objectContaining({
columnId: 'id1',
columnId: 'newid',
}),
expect.objectContaining({
columnId: 'colb',
@ -845,6 +863,7 @@ describe('IndexPattern Data Source suggestions', () => {
});
it('appends a terms column with default size on string field', () => {
(generateId as jest.Mock).mockReturnValue('newid');
const initialState = stateWithNonEmptyTables();
const suggestions = getDatasourceSuggestionsForField(initialState, '1', {
name: 'dest',
@ -853,17 +872,16 @@ describe('IndexPattern Data Source suggestions', () => {
aggregatable: true,
searchable: true,
});
expect(suggestions).toContainEqual(
expect.objectContaining({
state: expect.objectContaining({
layers: {
previousLayer: initialState.layers.previousLayer,
currentLayer: expect.objectContaining({
columnOrder: ['cola', 'id1', 'colb'],
columnOrder: ['cola', 'newid', 'colb'],
columns: {
...initialState.layers.currentLayer.columns,
id1: expect.objectContaining({
newid: expect.objectContaining({
operationType: 'terms',
sourceField: 'dest',
params: expect.objectContaining({ size: 3 }),
@ -877,6 +895,7 @@ describe('IndexPattern Data Source suggestions', () => {
});
it('suggests both replacing and adding metric if only one other metric is set', () => {
(generateId as jest.Mock).mockReturnValue('newid');
const initialState = stateWithNonEmptyTables();
const suggestions = getDatasourceSuggestionsForField(initialState, '1', {
name: 'memory',
@ -885,7 +904,6 @@ describe('IndexPattern Data Source suggestions', () => {
aggregatable: true,
searchable: true,
});
expect(suggestions).toContainEqual(
expect.objectContaining({
state: expect.objectContaining({
@ -910,11 +928,11 @@ describe('IndexPattern Data Source suggestions', () => {
state: expect.objectContaining({
layers: expect.objectContaining({
currentLayer: expect.objectContaining({
columnOrder: ['cola', 'colb', 'id1'],
columnOrder: ['cola', 'colb', 'newid'],
columns: {
cola: initialState.layers.currentLayer.columns.cola,
colb: initialState.layers.currentLayer.columns.colb,
id1: expect.objectContaining({
newid: expect.objectContaining({
operationType: 'avg',
sourceField: 'memory',
}),
@ -927,6 +945,7 @@ describe('IndexPattern Data Source suggestions', () => {
});
it('adds a metric column on a number field if no other metrics set', () => {
(generateId as jest.Mock).mockReturnValue('newid');
const initialState = stateWithNonEmptyTables();
const modifiedState: IndexPatternPrivateState = {
...initialState,
@ -955,10 +974,10 @@ describe('IndexPattern Data Source suggestions', () => {
layers: {
previousLayer: modifiedState.layers.previousLayer,
currentLayer: expect.objectContaining({
columnOrder: ['cola', 'id1'],
columnOrder: ['cola', 'newid'],
columns: {
...modifiedState.layers.currentLayer.columns,
id1: expect.objectContaining({
newid: expect.objectContaining({
operationType: 'avg',
sourceField: 'memory',
}),
@ -1008,6 +1027,137 @@ describe('IndexPattern Data Source suggestions', () => {
const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', documentField);
expect(suggestions).not.toContain(expect.objectContaining({ changeType: 'extended' }));
});
it('hides any referenced metrics when adding new metrics', () => {
(generateId as jest.Mock).mockReturnValue('newid');
const initialState = stateWithNonEmptyTables();
const modifiedState: IndexPatternPrivateState = {
...initialState,
layers: {
currentLayer: {
indexPatternId: '1',
columnOrder: ['date', 'metric', 'ref'],
columns: {
date: {
label: '',
customLabel: true,
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'timestamp',
params: { interval: 'auto' },
},
metric: {
label: '',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'avg',
sourceField: 'bytes',
},
ref: {
label: '',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric'],
},
},
},
},
};
const suggestions = getSuggestionSubset(
getDatasourceSuggestionsForField(modifiedState, '1', documentField)
);
expect(suggestions).toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
isMultiRow: true,
changeType: 'extended',
label: undefined,
layerId: 'currentLayer',
columns: [
{
columnId: 'date',
operation: expect.objectContaining({ dataType: 'date', isBucketed: true }),
},
{
columnId: 'newid',
operation: expect.objectContaining({ dataType: 'number', isBucketed: false }),
},
{
columnId: 'ref',
operation: expect.objectContaining({ dataType: 'number', isBucketed: false }),
},
],
}),
keptLayerIds: ['currentLayer'],
})
);
});
it('makes a suggestion to extending from an invalid state with a new metric', () => {
(generateId as jest.Mock).mockReturnValue('newid');
const initialState = stateWithNonEmptyTables();
const modifiedState: IndexPatternPrivateState = {
...initialState,
layers: {
currentLayer: {
indexPatternId: '1',
columnOrder: ['metric', 'ref'],
columns: {
metric: {
label: '',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'avg',
sourceField: 'bytes',
},
ref: {
label: '',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric'],
},
},
},
},
};
const suggestions = getSuggestionSubset(
getDatasourceSuggestionsForField(modifiedState, '1', documentField)
);
expect(suggestions).toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
changeType: 'extended',
columns: [
{
columnId: 'newid',
operation: {
dataType: 'number',
isBucketed: false,
label: 'Count of records',
scale: 'ratio',
},
},
{
columnId: 'ref',
operation: {
dataType: 'number',
isBucketed: false,
label: '',
scale: undefined,
},
},
],
}),
})
);
});
});
describe('finding the layer that is using the current index pattern', () => {
@ -1121,6 +1271,7 @@ describe('IndexPattern Data Source suggestions', () => {
});
});
});
describe('#getDatasourceSuggestionsForVisualizeField', () => {
describe('with no layer', () => {
function stateWithoutLayer() {
@ -1218,6 +1369,7 @@ describe('IndexPattern Data Source suggestions', () => {
columns: {
cola: {
label: 'My Op 2',
customLabel: true,
dataType: 'string',
isBucketed: true,
@ -1305,6 +1457,7 @@ describe('IndexPattern Data Source suggestions', () => {
columns: {
cola: {
label: 'My Op',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'avg',
@ -1316,7 +1469,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
};
expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual(
expect(getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state))).toContainEqual(
expect.objectContaining({
table: {
isMultiRow: true,
@ -1359,6 +1512,7 @@ describe('IndexPattern Data Source suggestions', () => {
columns: {
cola: {
label: 'My Terms',
customLabel: true,
dataType: 'string',
isBucketed: true,
operationType: 'terms',
@ -1372,6 +1526,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
colb: {
label: 'My Op',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'avg',
@ -1383,7 +1538,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
};
expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual(
expect(getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state))).toContainEqual(
expect.objectContaining({
table: {
isMultiRow: true,
@ -1442,6 +1597,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
colb: {
label: 'My Op',
customLabel: true,
dataType: 'number',
isBucketed: true,
operationType: 'range',
@ -1487,6 +1643,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
colb: {
label: 'My Custom Range',
customLabel: true,
dataType: 'string',
isBucketed: true,
operationType: 'range',
@ -1503,7 +1660,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
};
expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual(
expect(getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state))).toContainEqual(
expect.objectContaining({
table: {
changeType: 'extended',
@ -1555,6 +1712,7 @@ describe('IndexPattern Data Source suggestions', () => {
columns: {
id1: {
label: 'My Op',
customLabel: true,
dataType: 'number',
isBucketed: false,
operationType: 'avg',
@ -1631,6 +1789,7 @@ describe('IndexPattern Data Source suggestions', () => {
columns: {
col1: {
label: 'My Op',
customLabel: true,
dataType: 'string',
isBucketed: true,
@ -1644,6 +1803,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
col2: {
label: 'My Op',
customLabel: true,
dataType: 'string',
isBucketed: true,
@ -1657,6 +1817,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
col3: {
label: 'My Op',
customLabel: true,
dataType: 'string',
isBucketed: true,
@ -1670,6 +1831,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
col4: {
label: 'My Op',
customLabel: true,
dataType: 'number',
isBucketed: false,
@ -1678,6 +1840,7 @@ describe('IndexPattern Data Source suggestions', () => {
},
col5: {
label: 'My Op',
customLabel: true,
dataType: 'number',
isBucketed: false,
@ -1691,31 +1854,26 @@ describe('IndexPattern Data Source suggestions', () => {
};
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
// 1 bucket col, 2 metric cols
isTableWithBucketColumns(suggestions[0], ['col1', 'col4', 'col5'], 1);
// 3 bucket cols, 2 metric cols
isTableWithBucketColumns(suggestions[0], ['col1', 'col2', 'col3', 'col4', 'col5'], 3);
// 1 bucket col, 1 metric col
isTableWithBucketColumns(suggestions[1], ['col1', 'col4'], 1);
// 2 bucket cols, 2 metric cols
isTableWithBucketColumns(suggestions[2], ['col1', 'col2', 'col4', 'col5'], 2);
// 2 bucket cols, 1 metric col
isTableWithBucketColumns(suggestions[3], ['col1', 'col2', 'col4'], 2);
// 3 bucket cols, 2 metric cols
isTableWithBucketColumns(suggestions[4], ['col1', 'col2', 'col3', 'col4', 'col5'], 3);
isTableWithBucketColumns(suggestions[2], ['col1', 'col2', 'col4'], 2);
// 3 bucket cols, 1 metric col
isTableWithBucketColumns(suggestions[5], ['col1', 'col2', 'col3', 'col4'], 3);
isTableWithBucketColumns(suggestions[3], ['col1', 'col2', 'col3', 'col4'], 3);
// first metric col
isTableWithMetricColumns(suggestions[6], ['col4']);
isTableWithMetricColumns(suggestions[4], ['col4']);
// second metric col
isTableWithMetricColumns(suggestions[7], ['col5']);
isTableWithMetricColumns(suggestions[5], ['col5']);
expect(suggestions.length).toBe(8);
expect(suggestions.length).toBe(6);
});
it('returns an only metric version of a given table', () => {
@ -1770,7 +1928,7 @@ describe('IndexPattern Data Source suggestions', () => {
...initialState.layers.first,
columns: {
id1: {
label: 'Date histogram',
label: 'field2',
dataType: 'date',
isBucketed: true,
@ -1794,8 +1952,19 @@ describe('IndexPattern Data Source suggestions', () => {
},
};
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
expect(suggestions[1].table.columns[0].operation.label).toBe('Average of field1');
const suggestions = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
expect(suggestions).toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
changeType: 'reduced',
columns: [
expect.objectContaining({
operation: expect.objectContaining({ label: 'Average of field1' }),
}),
],
}),
})
);
});
it('returns an alternative metric for an only-metric table', () => {
@ -1848,9 +2017,18 @@ describe('IndexPattern Data Source suggestions', () => {
},
};
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
expect(suggestions[0].table.columns.length).toBe(1);
expect(suggestions[0].table.columns[0].operation.label).toBe('Sum of field1');
const suggestions = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
expect(suggestions).toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
columns: [
expect.objectContaining({
operation: expect.objectContaining({ label: 'Sum of field1' }),
}),
],
}),
})
);
});
it('contains a reordering suggestion when there are exactly 2 buckets', () => {
@ -1909,7 +2087,7 @@ describe('IndexPattern Data Source suggestions', () => {
);
});
it('does not generate suggestions if invalid fields are referenced', () => {
it('will generate suggestions even if there are errors from missing fields', () => {
const initialState = testInitialState();
const state: IndexPatternPrivateState = {
indexPatternRefs: [],
@ -1937,8 +2115,259 @@ describe('IndexPattern Data Source suggestions', () => {
},
};
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
expect(suggestions).toEqual([]);
const suggestions = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
expect(suggestions).toContainEqual(
expect.objectContaining({
table: {
changeType: 'unchanged',
columns: [
{
columnId: 'col1',
operation: {
dataType: 'string',
isBucketed: true,
label: 'My Op',
scale: undefined,
},
},
{
columnId: 'col2',
operation: {
dataType: 'string',
isBucketed: true,
label: 'Top 5',
scale: undefined,
},
},
],
isMultiRow: true,
label: undefined,
layerId: 'first',
},
})
);
});
describe('references', () => {
it('will extend the table with a date when starting in an invalid state', () => {
const initialState = testInitialState();
const state: IndexPatternPrivateState = {
...initialState,
layers: {
...initialState.layers,
first: {
...initialState.layers.first,
columnOrder: ['metric', 'ref', 'ref2'],
columns: {
metric: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'count',
sourceField: 'Records',
},
ref: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric'],
},
ref2: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric2'],
},
},
},
},
};
const result = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
expect(result).toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
changeType: 'extended',
layerId: 'first',
columns: [
{
columnId: 'id1',
operation: {
dataType: 'date',
isBucketed: true,
label: 'timestampLabel',
scale: 'interval',
},
},
{
columnId: 'ref',
operation: {
dataType: 'number',
isBucketed: false,
label: 'Cumulative sum of Records',
scale: undefined,
},
},
{
columnId: 'ref2',
operation: {
dataType: 'number',
isBucketed: false,
label: 'Cumulative sum of (incomplete)',
scale: undefined,
},
},
],
}),
keptLayerIds: ['first'],
})
);
});
it('will make an unchanged suggestion including incomplete references', () => {
const initialState = testInitialState();
const state: IndexPatternPrivateState = {
...initialState,
layers: {
...initialState.layers,
first: {
...initialState.layers.first,
columnOrder: ['date', 'ref', 'ref2'],
columns: {
date: {
label: '',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'timestamp',
params: { interval: 'auto' },
},
ref: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric'],
},
ref2: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric'],
},
},
},
},
};
const result = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
expect(result).toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
changeType: 'unchanged',
layerId: 'first',
columns: [
{
columnId: 'date',
operation: {
dataType: 'date',
isBucketed: true,
label: '',
scale: undefined,
},
},
{
columnId: 'ref',
operation: {
dataType: 'number',
isBucketed: false,
label: '',
scale: undefined,
},
},
{
columnId: 'ref2',
operation: {
dataType: 'number',
isBucketed: false,
label: '',
scale: undefined,
},
},
],
}),
keptLayerIds: ['first'],
})
);
});
it('will skip a reduced suggestion when handling multiple references', () => {
const initialState = testInitialState();
const state: IndexPatternPrivateState = {
...initialState,
layers: {
...initialState.layers,
first: {
...initialState.layers.first,
columnOrder: ['date', 'metric', 'metric2', 'ref', 'ref2'],
columns: {
date: {
label: '',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'timestamp',
params: { interval: 'auto' },
},
metric: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'count',
sourceField: 'Records',
},
ref: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric'],
},
metric2: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'count',
sourceField: 'Records',
},
ref2: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['metric2'],
},
},
},
},
};
const result = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
expect(result).not.toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
changeType: 'reduced',
}),
})
);
});
});
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import _, { partition } from 'lodash';
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { generateId } from '../id_generator';
import { DatasourceSuggestion, TableChangeType } from '../types';
@ -17,8 +17,10 @@ import {
operationDefinitionMap,
IndexPatternColumn,
OperationType,
getExistingColumnGroups,
isReferenced,
} from './operations';
import { hasField, hasInvalidColumns } from './utils';
import { hasField } from './utils';
import {
IndexPattern,
IndexPatternPrivateState,
@ -27,7 +29,7 @@ import {
} from './types';
import { documentField } from './document_field';
type IndexPatternSugestion = DatasourceSuggestion<IndexPatternPrivateState>;
export type IndexPatternSuggestion = DatasourceSuggestion<IndexPatternPrivateState>;
function buildSuggestion({
state,
@ -71,10 +73,13 @@ function buildSuggestion({
},
table: {
columns: columnOrder.map((columnId) => ({
columnId,
operation: columnToOperation(columnMap[columnId]),
})),
columns: columnOrder
// Hide any referenced columns from what visualizations know about
.filter((columnId) => !isReferenced(layers[layerId]!, columnId))
.map((columnId) => ({
columnId,
operation: columnToOperation(columnMap[columnId]),
})),
isMultiRow,
layerId,
changeType,
@ -89,8 +94,7 @@ export function getDatasourceSuggestionsForField(
state: IndexPatternPrivateState,
indexPatternId: string,
field: IndexPatternField
): IndexPatternSugestion[] {
if (hasInvalidColumns(state)) return [];
): IndexPatternSuggestion[] {
const layers = Object.keys(state.layers);
const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
@ -123,7 +127,7 @@ export function getDatasourceSuggestionsForVisualizeField(
state: IndexPatternPrivateState,
indexPatternId: string,
fieldName: string
): IndexPatternSugestion[] {
): IndexPatternSuggestion[] {
const layers = Object.keys(state.layers);
const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
// Identify the field by the indexPatternId and the fieldName
@ -158,7 +162,7 @@ function getExistingLayerSuggestionsForField(
const fieldInUse = Object.values(layer.columns).some(
(column) => hasField(column) && column.sourceField === field.name
);
const suggestions: IndexPatternSugestion[] = [];
const suggestions: IndexPatternSuggestion[] = [];
if (usableAsBucketOperation && !fieldInUse) {
if (
@ -221,8 +225,9 @@ function getExistingLayerSuggestionsForField(
);
}
const [, metrics] = separateBucketColumns(layer);
if (metrics.length === 1) {
const [, metrics, references] = getExistingColumnGroups(layer);
// TODO: Write test for the case where we have exactly one metric and one reference. We shouldn't switch the inner metric.
if (metrics.length === 1 && references.length === 0) {
const layerWithReplacedMetric = replaceColumn({
layer,
indexPattern,
@ -257,7 +262,7 @@ function getEmptyLayerSuggestionsForField(
layerId: string,
indexPatternId: string,
field: IndexPatternField
): IndexPatternSugestion[] {
): IndexPatternSuggestion[] {
const indexPattern = state.indexPatterns[indexPatternId];
let newLayer: IndexPatternLayer | undefined;
const bucketOperation = getBucketOperation(field);
@ -331,7 +336,6 @@ function createNewLayerWithMetricAggregation(
export function getDatasourceSuggestionsFromCurrentState(
state: IndexPatternPrivateState
): Array<DatasourceSuggestion<IndexPatternPrivateState>> {
if (hasInvalidColumns(state)) return [];
const layers = Object.entries(state.layers || {});
if (layers.length > 1) {
// Return suggestions that reduce the data to each layer individually
@ -372,12 +376,13 @@ export function getDatasourceSuggestionsFromCurrentState(
}),
]);
}
return _.flatten(
Object.entries(state.layers || {})
.filter(([_id, layer]) => layer.columnOrder.length && layer.indexPatternId)
.map(([layerId, layer]) => {
const indexPattern = state.indexPatterns[layer.indexPatternId];
const [buckets, metrics] = separateBucketColumns(layer);
const [buckets, metrics, references] = getExistingColumnGroups(layer);
const timeDimension = layer.columnOrder.find(
(columnId) =>
layer.columns[columnId].isBucketed && layer.columns[columnId].dataType === 'date'
@ -390,29 +395,22 @@ export function getDatasourceSuggestionsFromCurrentState(
buckets.some((columnId) => layer.columns[columnId].dataType === 'number');
const suggestions: Array<DatasourceSuggestion<IndexPatternPrivateState>> = [];
if (metrics.length === 0) {
// intermediary chart without metric, don't try to suggest reduced versions
suggestions.push(
buildSuggestion({
state,
layerId,
changeType: 'unchanged',
})
);
} else if (buckets.length === 0) {
// Always suggest an unchanged table, including during invalid states
suggestions.push(
buildSuggestion({
state,
layerId,
changeType: 'unchanged',
})
);
if (!references.length && metrics.length && buckets.length === 0) {
if (timeField) {
// suggest current metric over time if there is a default time field
suggestions.push(createSuggestionWithDefaultDateHistogram(state, layerId, timeField));
}
suggestions.push(...createAlternativeMetricSuggestions(indexPattern, layerId, state));
// also suggest simple current state
suggestions.push(
buildSuggestion({
state,
layerId,
changeType: 'unchanged',
})
);
} else {
suggestions.push(...createSimplifiedTableSuggestions(state, layerId));
@ -570,7 +568,11 @@ function createSuggestionWithDefaultDateHistogram(
function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layerId: string) {
const layer = state.layers[layerId];
const [availableBucketedColumns, availableMetricColumns] = separateBucketColumns(layer);
const [
availableBucketedColumns,
availableMetricColumns,
availableReferenceColumns,
] = getExistingColumnGroups(layer);
return _.flatten(
availableBucketedColumns.map((_col, index) => {
@ -581,21 +583,23 @@ function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layer
columnOrder: [...bucketedColumns, ...availableMetricColumns],
};
if (availableMetricColumns.length > 1) {
return [
allMetricsSuggestion,
{ ...layer, columnOrder: [...bucketedColumns, availableMetricColumns[0]] },
];
if (availableReferenceColumns.length) {
// Don't remove buckets when dealing with any refs. This can break refs.
return [];
} else if (availableMetricColumns.length > 1) {
return [{ ...layer, columnOrder: [...bucketedColumns, availableMetricColumns[0]] }];
} else {
return allMetricsSuggestion;
}
})
)
.concat(
availableMetricColumns.map((columnId) => {
// build suggestions with only metrics
return { ...layer, columnOrder: [columnId] };
})
availableReferenceColumns.length
? []
: availableMetricColumns.map((columnId) => {
// build suggestions with only metrics
return { ...layer, columnOrder: [columnId] };
})
)
.map((updatedLayer) => {
return buildSuggestion({
@ -623,7 +627,3 @@ function getMetricSuggestionTitle(layer: IndexPatternLayer, onlyMetric: boolean)
'Title of a suggested chart containing only a single numerical metric calculated over all available data',
});
}
function separateBucketColumns(layer: IndexPatternLayer) {
return partition(layer.columnOrder, (columnId) => layer.columns[columnId].isBucketed);
}

View file

@ -42,6 +42,7 @@ export const {
getErrorMessages,
isReferenced,
resetIncomplete,
isOperationAllowedAsReference,
} = actualHelpers;
export const { adjustTimeScaleLabelSuffix, DEFAULT_TIME_SCALE } = actualTimeScaleUtils;

View file

@ -9,6 +9,7 @@ import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '.
import { IndexPatternLayer } from '../../../types';
import {
buildLabelFunction,
getErrorsForDateReference,
checkForDateHistogram,
dateBasedOperationToExpression,
hasDateField,
@ -52,15 +53,18 @@ export const counterRateOperation: OperationDefinition<
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
},
],
getPossibleOperation: () => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
getPossibleOperation: (indexPattern) => {
if (hasDateField(indexPattern)) {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
}
},
getDefaultLabel: (column, indexPattern, columns) => {
return ofName(columns[column.references[0]]?.label, column.timeScale);
const ref = columns[column.references[0]];
return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined, column.timeScale);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'lens_counter_rate');
@ -69,7 +73,7 @@ export const counterRateOperation: OperationDefinition<
const metric = layer.columns[referenceIds[0]];
const timeScale = previousColumn?.timeScale || DEFAULT_TIME_SCALE;
return {
label: ofName(metric?.label, timeScale),
label: ofName(metric && 'sourceField' in metric ? metric.sourceField : undefined, timeScale),
dataType: 'number',
operationType: 'counter_rate',
isBucketed: false,
@ -88,13 +92,22 @@ export const counterRateOperation: OperationDefinition<
isTransferable: (column, newIndexPattern) => {
return hasDateField(newIndexPattern);
},
getErrorMessage: (layer: IndexPatternLayer) => {
return checkForDateHistogram(
getErrorMessage: (layer: IndexPatternLayer, columnId: string) => {
return getErrorsForDateReference(
layer,
columnId,
i18n.translate('xpack.lens.indexPattern.counterRate', {
defaultMessage: 'Counter rate',
})
);
},
getDisabledStatus(indexPattern, layer) {
return checkForDateHistogram(
layer,
i18n.translate('xpack.lens.indexPattern.counterRate', {
defaultMessage: 'Counter rate',
})
)?.join(', ');
},
timeScalingMode: 'mandatory',
};

View file

@ -7,12 +7,17 @@
import { i18n } from '@kbn/i18n';
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
import { IndexPatternLayer } from '../../../types';
import { checkForDateHistogram, dateBasedOperationToExpression } from './utils';
import {
checkForDateHistogram,
getErrorsForDateReference,
dateBasedOperationToExpression,
hasDateField,
} from './utils';
import { OperationDefinition } from '..';
const ofName = (name?: string) => {
return i18n.translate('xpack.lens.indexPattern.cumulativeSumOf', {
defaultMessage: 'Cumulative sum rate of {name}',
defaultMessage: 'Cumulative sum of {name}',
values: {
name:
name ??
@ -46,23 +51,26 @@ export const cumulativeSumOperation: OperationDefinition<
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
},
],
getPossibleOperation: () => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
getPossibleOperation: (indexPattern) => {
if (hasDateField(indexPattern)) {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
}
},
getDefaultLabel: (column, indexPattern, columns) => {
return ofName(columns[column.references[0]]?.label);
const ref = columns[column.references[0]];
return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'cumulative_sum');
},
buildColumn: ({ referenceIds, previousColumn, layer }) => {
const metric = layer.columns[referenceIds[0]];
const ref = layer.columns[referenceIds[0]];
return {
label: ofName(metric?.label),
label: ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined),
dataType: 'number',
operationType: 'cumulative_sum',
isBucketed: false,
@ -80,12 +88,21 @@ export const cumulativeSumOperation: OperationDefinition<
isTransferable: () => {
return true;
},
getErrorMessage: (layer: IndexPatternLayer) => {
return checkForDateHistogram(
getErrorMessage: (layer: IndexPatternLayer, columnId: string) => {
return getErrorsForDateReference(
layer,
columnId,
i18n.translate('xpack.lens.indexPattern.cumulativeSum', {
defaultMessage: 'Cumulative sum',
})
);
},
getDisabledStatus(indexPattern, layer) {
return checkForDateHistogram(
layer,
i18n.translate('xpack.lens.indexPattern.cumulativeSum', {
defaultMessage: 'Cumulative sum',
})
)?.join(', ');
},
};

View file

@ -10,6 +10,7 @@ import { IndexPatternLayer } from '../../../types';
import {
buildLabelFunction,
checkForDateHistogram,
getErrorsForDateReference,
dateBasedOperationToExpression,
hasDateField,
} from './utils';
@ -51,23 +52,29 @@ export const derivativeOperation: OperationDefinition<
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
},
],
getPossibleOperation: () => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
getPossibleOperation: (indexPattern) => {
if (hasDateField(indexPattern)) {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
}
},
getDefaultLabel: (column, indexPattern, columns) => {
return ofName(columns[column.references[0]]?.label, column.timeScale);
const ref = columns[column.references[0]];
return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined, column.timeScale);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'derivative');
},
buildColumn: ({ referenceIds, previousColumn, layer }) => {
const metric = layer.columns[referenceIds[0]];
const ref = layer.columns[referenceIds[0]];
return {
label: ofName(metric?.label, previousColumn?.timeScale),
label: ofName(
ref && 'sourceField' in ref ? ref.sourceField : undefined,
previousColumn?.timeScale
),
dataType: 'number',
operationType: 'derivative',
isBucketed: false,
@ -87,13 +94,22 @@ export const derivativeOperation: OperationDefinition<
return hasDateField(newIndexPattern);
},
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
getErrorMessage: (layer: IndexPatternLayer) => {
return checkForDateHistogram(
getErrorMessage: (layer: IndexPatternLayer, columnId: string) => {
return getErrorsForDateReference(
layer,
columnId,
i18n.translate('xpack.lens.indexPattern.derivative', {
defaultMessage: 'Differences',
})
);
},
getDisabledStatus(indexPattern, layer) {
return checkForDateHistogram(
layer,
i18n.translate('xpack.lens.indexPattern.derivative', {
defaultMessage: 'Differences',
})
)?.join(', ');
},
timeScalingMode: 'optional',
};

View file

@ -14,6 +14,7 @@ import { IndexPatternLayer } from '../../../types';
import {
buildLabelFunction,
checkForDateHistogram,
getErrorsForDateReference,
dateBasedOperationToExpression,
hasDateField,
} from './utils';
@ -50,7 +51,7 @@ export const movingAverageOperation: OperationDefinition<
type: 'moving_average',
priority: 1,
displayName: i18n.translate('xpack.lens.indexPattern.movingAverage', {
defaultMessage: 'Moving Average',
defaultMessage: 'Moving average',
}),
input: 'fullReference',
selectionStyle: 'full',
@ -60,12 +61,14 @@ export const movingAverageOperation: OperationDefinition<
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
},
],
getPossibleOperation: () => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
getPossibleOperation: (indexPattern) => {
if (hasDateField(indexPattern)) {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
}
},
getDefaultLabel: (column, indexPattern, columns) => {
return ofName(columns[column.references[0]]?.label, column.timeScale);
@ -99,17 +102,39 @@ export const movingAverageOperation: OperationDefinition<
return hasDateField(newIndexPattern);
},
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
getErrorMessage: (layer: IndexPatternLayer) => {
return checkForDateHistogram(
getErrorMessage: (layer: IndexPatternLayer, columnId: string) => {
return getErrorsForDateReference(
layer,
columnId,
i18n.translate('xpack.lens.indexPattern.movingAverage', {
defaultMessage: 'Moving Average',
defaultMessage: 'Moving average',
})
);
},
getDisabledStatus(indexPattern, layer) {
return checkForDateHistogram(
layer,
i18n.translate('xpack.lens.indexPattern.movingAverage', {
defaultMessage: 'Moving average',
})
)?.join(', ');
},
timeScalingMode: 'optional',
};
function isValidNumber(input: string) {
if (input === '') return false;
try {
const val = parseFloat(input);
if (isNaN(val)) return false;
if (val < 1) return false;
if (val.toString().includes('.')) return false;
} catch (e) {
return false;
}
return true;
}
function MovingAverageParamEditor({
layer,
updateLayer,
@ -120,10 +145,8 @@ function MovingAverageParamEditor({
useDebounceWithOptions(
() => {
if (inputValue === '') {
return;
}
const inputNumber = Number(inputValue);
if (!isValidNumber(inputValue)) return;
const inputNumber = parseInt(inputValue, 10);
updateLayer(
updateColumnParam({
layer,
@ -137,6 +160,7 @@ function MovingAverageParamEditor({
256,
[inputValue]
);
return (
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.movingAverage.window', {
@ -144,11 +168,15 @@ function MovingAverageParamEditor({
})}
display="columnCompressed"
fullWidth
isInvalid={!isValidNumber(inputValue)}
>
<EuiFieldNumber
compressed
value={inputValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value)}
min={1}
step={1}
isInvalid={!isValidNumber(inputValue)}
/>
</EuiFormRow>
);

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { checkReferences } from './utils';
import { operationDefinitionMap } from '..';
import { createMockedReferenceOperation } from '../../mocks';
// Mock prevents issue with circular loading
jest.mock('..');
describe('utils', () => {
beforeEach(() => {
// @ts-expect-error test-only operation type
operationDefinitionMap.testReference = createMockedReferenceOperation();
});
describe('checkReferences', () => {
it('should show an error if the reference is missing', () => {
expect(
checkReferences(
{
columns: {
ref: {
label: 'Label',
// @ts-expect-error test-only operation type
operationType: 'testReference',
isBucketed: false,
dataType: 'number',
references: ['missing'],
},
},
columnOrder: ['ref'],
indexPatternId: '',
},
'ref'
)
).toEqual(['"Label" is not fully configured']);
});
it('should show an error if the reference is not allowed per the requirements', () => {
expect(
checkReferences(
{
columns: {
ref: {
label: 'Label',
// @ts-expect-error test-only operation type
operationType: 'testReference',
isBucketed: false,
dataType: 'number',
references: ['invalid'],
},
invalid: {
label: 'Date',
operationType: 'date_histogram',
isBucketed: true,
dataType: 'date',
sourceField: 'timestamp',
params: { interval: 'auto' },
},
},
columnOrder: ['invalid', 'ref'],
indexPatternId: '',
},
'ref'
)
).toEqual(['Dimension "Label" is configured incorrectly']);
});
});
});

View file

@ -5,11 +5,13 @@
*/
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionAST } from '@kbn/interpreter/common';
import { TimeScaleUnit } from '../../../time_scale';
import { IndexPattern, IndexPatternLayer } from '../../../types';
import type { ExpressionFunctionAST } from '@kbn/interpreter/common';
import type { TimeScaleUnit } from '../../../time_scale';
import type { IndexPattern, IndexPatternLayer } from '../../../types';
import { adjustTimeScaleLabelSuffix } from '../../time_scale_utils';
import { ReferenceBasedIndexPatternColumn } from '../column_types';
import type { ReferenceBasedIndexPatternColumn } from '../column_types';
import { operationDefinitionMap } from '..';
import type { IndexPatternColumn, RequiredReference } from '..';
export const buildLabelFunction = (ofName: (name?: string) => string) => (
name?: string,
@ -41,6 +43,78 @@ export function checkForDateHistogram(layer: IndexPatternLayer, name: string) {
];
}
export function checkReferences(layer: IndexPatternLayer, columnId: string) {
const column = layer.columns[columnId] as ReferenceBasedIndexPatternColumn;
const errors: string[] = [];
column.references.forEach((referenceId, index) => {
if (!layer.columns[referenceId]) {
errors.push(
i18n.translate('xpack.lens.indexPattern.missingReferenceError', {
defaultMessage: '"{dimensionLabel}" is not fully configured',
values: {
dimensionLabel: column.label,
},
})
);
} else {
const referenceColumn = layer.columns[referenceId]!;
const definition = operationDefinitionMap[column.operationType];
if (definition.input !== 'fullReference') {
throw new Error('inconsistent state - column is not a reference operation');
}
const requirements = definition.requiredReferences[index];
const isValid = isColumnValidAsReference({
validation: requirements,
column: referenceColumn,
});
if (!isValid) {
errors.push(
i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', {
defaultMessage: 'Dimension "{dimensionLabel}" is configured incorrectly',
values: {
dimensionLabel: column.label,
},
})
);
}
}
});
return errors.length ? errors : undefined;
}
export function isColumnValidAsReference({
column,
validation,
}: {
column: IndexPatternColumn;
validation: RequiredReference;
}): boolean {
if (!column) return false;
const operationType = column.operationType;
const operationDefinition = operationDefinitionMap[operationType];
return (
validation.input.includes(operationDefinition.input) &&
(!validation.specificOperations || validation.specificOperations.includes(operationType)) &&
validation.validateMetadata(column)
);
}
export function getErrorsForDateReference(
layer: IndexPatternLayer,
columnId: string,
name: string
) {
const dateErrors = checkForDateHistogram(layer, name) ?? [];
const referenceErrors = checkReferences(layer, columnId) ?? [];
if (dateErrors.length || referenceErrors.length) {
return [...dateErrors, ...referenceErrors];
}
return;
}
export function hasDateField(indexPattern: IndexPattern) {
return indexPattern.fields.some((field) => field.type === 'date');
}

View file

@ -10,7 +10,7 @@ import { buildExpressionFunction } from '../../../../../../../src/plugins/expres
import { OperationDefinition } from './index';
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
import { getInvalidFieldMessage } from './helpers';
import { getInvalidFieldMessage, getSafeName } from './helpers';
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
@ -21,7 +21,9 @@ const IS_BUCKETED = false;
function ofName(name: string) {
return i18n.translate('xpack.lens.indexPattern.cardinalityOf', {
defaultMessage: 'Unique count of {name}',
values: { name },
values: {
name,
},
});
}
@ -58,8 +60,7 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo
(!newField.aggregationRestrictions || newField.aggregationRestrictions.cardinality)
);
},
getDefaultLabel: (column, indexPattern) =>
ofName(indexPattern.getFieldByName(column.sourceField)!.displayName),
getDefaultLabel: (column, indexPattern) => ofName(getSafeName(column.sourceField, indexPattern)),
buildColumn({ field, previousColumn }) {
return {
label: ofName(field.displayName),

View file

@ -69,7 +69,12 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
: undefined,
};
},
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
onOtherColumnChanged: (layer, thisColumnId, changedColumnId) =>
adjustTimeScaleOnOtherColumnChange<CountIndexPatternColumn>(
layer,
thisColumnId,
changedColumnId
),
toEsAggsFn: (column, columnId) => {
return buildExpressionFunction<AggFunctionsMapping['aggCount']>('aggCount', {
id: columnId,

View file

@ -689,4 +689,32 @@ describe('date_histogram', () => {
expect(instance.find('[data-test-subj="lensDateHistogramValue"]').exists()).toBeFalsy();
});
});
describe('getDefaultLabel', () => {
it('should not throw when the source field is not located', () => {
expect(
dateHistogramOperation.getDefaultLabel(
{
label: '',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'missing',
params: { interval: 'auto' },
},
indexPattern1,
{
col1: {
label: '',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'missing',
params: { interval: 'auto' },
},
}
)
).toEqual('Missing field');
});
});
});

View file

@ -28,7 +28,7 @@ import {
search,
} from '../../../../../../../src/plugins/data/public';
import { buildExpressionFunction } from '../../../../../../../src/plugins/expressions/public';
import { getInvalidFieldMessage } from './helpers';
import { getInvalidFieldMessage, getSafeName } from './helpers';
const { isValidInterval } = search.aggs;
const autoInterval = 'auto';
@ -67,8 +67,7 @@ export const dateHistogramOperation: OperationDefinition<
};
}
},
getDefaultLabel: (column, indexPattern) =>
indexPattern.getFieldByName(column.sourceField)!.displayName,
getDefaultLabel: (column, indexPattern) => getSafeName(column.sourceField, indexPattern),
buildColumn({ field }) {
let interval = autoInterval;
let timeZone: string | undefined;

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createMockedIndexPattern } from '../../mocks';
import { getInvalidFieldMessage } from './helpers';
describe('helpers', () => {
describe('getInvalidFieldMessage', () => {
it('return an error if a field was removed', () => {
const messages = getInvalidFieldMessage(
{
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'bytes',
},
createMockedIndexPattern()
);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual('Field bytes was not found');
});
it('returns an error if a field is the wrong type', () => {
const messages = getInvalidFieldMessage(
{
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'avg', // <= invalid
sourceField: 'timestamp',
},
createMockedIndexPattern()
);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual('Field timestamp was not found');
});
it('returns no message if all fields are matching', () => {
const messages = getInvalidFieldMessage(
{
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'avg',
sourceField: 'bytes',
},
createMockedIndexPattern()
);
expect(messages).toBeUndefined();
});
});
});

View file

@ -62,3 +62,12 @@ export function getInvalidFieldMessage(
]
: undefined;
}
export function getSafeName(name: string, indexPattern: IndexPattern): string {
const field = indexPattern.getFieldByName(name);
return field
? field.displayName
: i18n.translate('xpack.lens.indexPattern.missingFieldLabel', {
defaultMessage: 'Missing field',
});
}

View file

@ -152,8 +152,9 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
* return an updated column. If not implemented, the `id` function is used instead.
*/
onOtherColumnChanged?: (
currentColumn: C,
columns: Partial<Record<string, IndexPatternColumn>>
layer: IndexPatternLayer,
thisColumnId: string,
changedColumnId: string
) => C;
/**
* React component for operation specific settings shown in the popover editor
@ -176,7 +177,7 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
* but disable it from usage, this function returns the string describing
* the status. Otherwise it returns undefined
*/
getDisabledStatus?: (indexPattern: IndexPattern) => string | undefined;
getDisabledStatus?: (indexPattern: IndexPattern, layer: IndexPatternLayer) => string | undefined;
/**
* Validate that the operation has the right preconditions in the state. For example:
*
@ -314,9 +315,9 @@ interface FullReferenceOperationDefinition<C extends BaseIndexPatternColumn> {
) => ReferenceBasedIndexPatternColumn & C;
/**
* Returns the meta data of the operation if applied. Undefined
* if the field is not applicable.
* if the operation can't be added with these fields.
*/
getPossibleOperation: () => OperationMetadata;
getPossibleOperation: (indexPattern: IndexPattern) => OperationMetadata | undefined;
/**
* A chain of expression functions which will transform the table
*/

View file

@ -311,13 +311,13 @@ describe('last_value', () => {
it('should return disabledStatus if indexPattern does contain date field', () => {
const indexPattern = createMockedIndexPattern();
expect(lastValueOperation.getDisabledStatus!(indexPattern)).toEqual(undefined);
expect(lastValueOperation.getDisabledStatus!(indexPattern, layer)).toEqual(undefined);
const indexPatternWithoutTimeFieldName = {
...indexPattern,
timeFieldName: undefined,
};
expect(lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName)).toEqual(
expect(lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName, layer)).toEqual(
undefined
);
@ -326,7 +326,10 @@ describe('last_value', () => {
fields: indexPattern.fields.filter((f) => f.type !== 'date'),
};
const disabledStatus = lastValueOperation.getDisabledStatus!(indexPatternWithoutTimefields);
const disabledStatus = lastValueOperation.getDisabledStatus!(
indexPatternWithoutTimefields,
layer
);
expect(disabledStatus).toEqual(
'This function requires the presence of a date field in your index'
);

View file

@ -13,12 +13,14 @@ import { FieldBasedIndexPatternColumn } from './column_types';
import { IndexPatternField, IndexPattern } from '../../types';
import { updateColumnParam } from '../layer_helpers';
import { DataType } from '../../../types';
import { getInvalidFieldMessage } from './helpers';
import { getInvalidFieldMessage, getSafeName } from './helpers';
function ofName(name: string) {
return i18n.translate('xpack.lens.indexPattern.lastValueOf', {
defaultMessage: 'Last value of {name}',
values: { name },
values: {
name,
},
});
}
@ -87,8 +89,7 @@ export const lastValueOperation: OperationDefinition<LastValueIndexPatternColumn
displayName: i18n.translate('xpack.lens.indexPattern.lastValue', {
defaultMessage: 'Last value',
}),
getDefaultLabel: (column, indexPattern) =>
indexPattern.getFieldByName(column.sourceField)!.displayName,
getDefaultLabel: (column, indexPattern) => ofName(getSafeName(column.sourceField, indexPattern)),
input: 'field',
onFieldChange: (oldColumn, field) => {
const newParams = { ...oldColumn.params };

View file

@ -7,7 +7,7 @@
import { i18n } from '@kbn/i18n';
import { buildExpressionFunction } from '../../../../../../../src/plugins/expressions/public';
import { OperationDefinition } from './index';
import { getInvalidFieldMessage } from './helpers';
import { getInvalidFieldMessage, getSafeName } from './helpers';
import {
FormattedIndexPatternColumn,
FieldBasedIndexPatternColumn,
@ -45,11 +45,11 @@ function buildMetricOperation<T extends MetricColumn<string>>({
optionalTimeScaling?: boolean;
}) {
const labelLookup = (name: string, column?: BaseIndexPatternColumn) => {
const rawLabel = ofName(name);
const label = ofName(name);
if (!optionalTimeScaling) {
return rawLabel;
return label;
}
return adjustTimeScaleLabelSuffix(rawLabel, undefined, column?.timeScale);
return adjustTimeScaleLabelSuffix(label, undefined, column?.timeScale);
};
return {
@ -81,10 +81,12 @@ function buildMetricOperation<T extends MetricColumn<string>>({
(!newField.aggregationRestrictions || newField.aggregationRestrictions![type])
);
},
onOtherColumnChanged: (column, otherColumns) =>
optionalTimeScaling ? adjustTimeScaleOnOtherColumnChange(column, otherColumns) : column,
onOtherColumnChanged: (layer, thisColumnId, changedColumnId) =>
optionalTimeScaling
? adjustTimeScaleOnOtherColumnChange(layer, thisColumnId, changedColumnId)
: layer.columns[thisColumnId],
getDefaultLabel: (column, indexPattern, columns) =>
labelLookup(indexPattern.getFieldByName(column.sourceField)!.displayName, column),
labelLookup(getSafeName(column.sourceField, indexPattern), column),
buildColumn: ({ field, previousColumn }) => ({
label: labelLookup(field.displayName, previousColumn),
dataType: 'number',

View file

@ -98,7 +98,10 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field
}
},
getDefaultLabel: (column, indexPattern) =>
indexPattern.getFieldByName(column.sourceField)!.displayName,
indexPattern.getFieldByName(column.sourceField)?.displayName ??
i18n.translate('xpack.lens.indexPattern.missingFieldLabel', {
defaultMessage: 'Missing field',
}),
buildColumn({ field }) {
return {
label: field.displayName,

View file

@ -18,23 +18,36 @@ import {
} from '@elastic/eui';
import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public';
import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public';
import { IndexPatternColumn } from '../../../indexpattern';
import { updateColumnParam, isReferenced } from '../../layer_helpers';
import { DataType } from '../../../../types';
import { OperationDefinition } from '../index';
import { FieldBasedIndexPatternColumn } from '../column_types';
import { ValuesRangeInput } from './values_range_input';
import { getInvalidFieldMessage } from '../helpers';
import type { IndexPatternLayer } from '../../../types';
function ofName(name: string) {
function ofName(name?: string) {
return i18n.translate('xpack.lens.indexPattern.termsOf', {
defaultMessage: 'Top values of {name}',
values: { name },
values: {
name:
name ??
i18n.translate('xpack.lens.indexPattern.missingFieldLabel', {
defaultMessage: 'Missing field',
}),
},
});
}
function isSortableByColumn(column: IndexPatternColumn) {
return !column.isBucketed && column.operationType !== 'last_value';
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)
);
}
const DEFAULT_SIZE = 3;
@ -89,10 +102,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
},
buildColumn({ layer, field, indexPattern }) {
const existingMetricColumn = Object.entries(layer.columns)
.filter(
([columnId, column]) =>
column && !isReferenced(layer, columnId) && isSortableByColumn(column)
)
.filter(([columnId]) => isSortableByColumn(layer, columnId))
.map(([id]) => id)[0];
const previousBucketsLength = Object.values(layer.columns).filter(
@ -138,7 +148,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
}).toAst();
},
getDefaultLabel: (column, indexPattern) =>
ofName(indexPattern.getFieldByName(column.sourceField)!.displayName),
ofName(indexPattern.getFieldByName(column.sourceField)?.displayName),
onFieldChange: (oldColumn, field) => {
const newParams = { ...oldColumn.params };
if ('format' in newParams && field.type !== 'number') {
@ -152,11 +162,13 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
params: newParams,
};
},
onOtherColumnChanged: (currentColumn, columns) => {
onOtherColumnChanged: (layer, thisColumnId, changedColumnId) => {
const columns = layer.columns;
const currentColumn = columns[thisColumnId] as TermsIndexPatternColumn;
if (currentColumn.params.orderBy.type === 'column') {
// check whether the column is still there and still a metric
const columnSortedBy = columns[currentColumn.params.orderBy.columnId];
if (!columnSortedBy || !isSortableByColumn(columnSortedBy)) {
if (!columnSortedBy || !isSortableByColumn(layer, changedColumnId)) {
return {
...currentColumn,
params: {
@ -194,7 +206,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
}
const orderOptions = Object.entries(layer.columns)
.filter(([sortId, column]) => isSortableByColumn(column))
.filter(([sortId]) => isSortableByColumn(layer, sortId))
.map(([sortId, column]) => {
return {
value: toValue({ type: 'column', columnId: sortId }),

View file

@ -402,15 +402,25 @@ describe('terms', () => {
},
sourceField: 'category',
};
const updatedColumn = termsOperation.onOtherColumnChanged!(initialColumn, {
col1: {
label: 'Count',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
const updatedColumn = termsOperation.onOtherColumnChanged!(
{
indexPatternId: '',
columnOrder: [],
columns: {
col2: initialColumn,
col1: {
label: 'Count',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
},
},
});
'col2',
'col1'
);
expect(updatedColumn).toBe(initialColumn);
});
@ -429,18 +439,74 @@ describe('terms', () => {
},
sourceField: 'category',
};
const updatedColumn = termsOperation.onOtherColumnChanged!(initialColumn, {
col1: {
label: 'Last Value',
dataType: 'number',
isBucketed: false,
sourceField: 'bytes',
operationType: 'last_value',
params: {
sortField: 'time',
const updatedColumn = termsOperation.onOtherColumnChanged!(
{
columns: {
col2: initialColumn,
col1: {
label: 'Last Value',
dataType: 'number',
isBucketed: false,
sourceField: 'bytes',
operationType: 'last_value',
params: {
sortField: 'time',
},
},
},
columnOrder: [],
indexPatternId: '',
},
});
'col2',
'col1'
);
expect(updatedColumn.params).toEqual(
expect.objectContaining({
orderBy: { type: 'alphabetical' },
})
);
});
it('should switch to alphabetical ordering if metric is reference-based', () => {
const initialColumn: TermsIndexPatternColumn = {
label: 'Top value of category',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
params: {
orderBy: { type: 'column', columnId: 'col1' },
size: 3,
orderDirection: 'asc',
},
sourceField: 'category',
};
const updatedColumn = termsOperation.onOtherColumnChanged!(
{
columns: {
col2: initialColumn,
col1: {
label: 'Cumulative sum',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['referenced'],
},
referenced: {
label: '',
dataType: 'number',
isBucketed: false,
operationType: 'count',
sourceField: 'Records',
},
},
columnOrder: [],
indexPatternId: '',
},
'col2',
'col1'
);
expect(updatedColumn.params).toEqual(
expect.objectContaining({
orderBy: { type: 'alphabetical' },
@ -451,20 +517,27 @@ describe('terms', () => {
it('should switch to alphabetical ordering if there are no columns to order by', () => {
const termsColumn = termsOperation.onOtherColumnChanged!(
{
label: 'Top value of category',
dataType: 'string',
isBucketed: true,
columns: {
col2: {
label: 'Top value of category',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
params: {
orderBy: { type: 'column', columnId: 'col1' },
size: 3,
orderDirection: 'asc',
// Private
operationType: 'terms',
params: {
orderBy: { type: 'column', columnId: 'col1' },
size: 3,
orderDirection: 'asc',
},
sourceField: 'category',
},
},
sourceField: 'category',
columnOrder: [],
indexPatternId: '',
},
{}
'col2',
'col1'
);
expect(termsColumn.params).toEqual(
expect.objectContaining({
@ -476,33 +549,39 @@ describe('terms', () => {
it('should switch to alphabetical ordering if the order column is not a metric anymore', () => {
const termsColumn = termsOperation.onOtherColumnChanged!(
{
label: 'Top value of category',
dataType: 'string',
isBucketed: true,
columns: {
col2: {
label: 'Top value of category',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
params: {
orderBy: { type: 'column', columnId: 'col1' },
size: 3,
orderDirection: 'asc',
},
sourceField: 'category',
},
{
col1: {
label: 'Value of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'date_histogram',
params: {
interval: 'w',
// Private
operationType: 'terms',
params: {
orderBy: { type: 'column', columnId: 'col1' },
size: 3,
orderDirection: 'asc',
},
sourceField: 'category',
},
col1: {
label: 'Value of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'date_histogram',
params: {
interval: 'w',
},
sourceField: 'timestamp',
},
sourceField: 'timestamp',
},
}
columnOrder: [],
indexPatternId: '',
},
'col2',
'col1'
);
expect(termsColumn.params).toEqual(
expect.objectContaining({

View file

@ -12,6 +12,7 @@ export {
IndexPatternColumn,
FieldBasedIndexPatternColumn,
IncompleteColumn,
RequiredReference,
} from './definitions';
export { createMockedReferenceOperation } from './mocks';

View file

@ -190,6 +190,44 @@ describe('state_helpers', () => {
).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2'] }));
});
it('should insert a metric after buckets, but before references', () => {
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Date histogram of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'date_histogram',
sourceField: 'timestamp',
params: {
interval: 'h',
},
},
col3: {
label: 'Reference',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['col2'],
},
},
};
expect(
insertNewColumn({
layer,
indexPattern,
columnId: 'col2',
op: 'count',
field: documentField,
})
).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2', 'col3'] }));
});
it('should insert new buckets at the end of previous buckets', () => {
const layer: IndexPatternLayer = {
indexPatternId: '1',
@ -782,18 +820,83 @@ describe('state_helpers', () => {
field: indexPattern.fields[2], // bytes field
});
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, {
col1: termsColumn,
col2: expect.objectContaining({
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(
{
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: termsColumn,
col2: expect.objectContaining({
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
sourceField: 'bytes',
operationType: 'avg',
}),
},
incompleteColumns: {},
},
'col1',
'col2'
);
});
// Private
operationType: 'avg',
sourceField: 'bytes',
}),
it('should execute adjustments for other columns when creating a reference', () => {
const termsColumn: TermsIndexPatternColumn = {
label: 'Top values of source',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
sourceField: 'source',
params: {
orderBy: { type: 'column', columnId: 'willBeReference' },
orderDirection: 'desc',
size: 5,
},
};
replaceColumn({
layer: {
indexPatternId: '1',
columnOrder: ['col1', 'willBeReference'],
columns: {
col1: termsColumn,
willBeReference: {
label: 'Count',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
},
},
indexPattern,
columnId: 'willBeReference',
op: 'cumulative_sum',
});
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(
{
indexPatternId: '1',
columnOrder: ['col1', 'willBeReference'],
columns: {
col1: {
...termsColumn,
params: { orderBy: { type: 'alphabetical' }, orderDirection: 'asc', size: 5 },
},
willBeReference: expect.objectContaining({
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
}),
},
incompleteColumns: {},
},
'col1',
'willBeReference'
);
});
it('should not wrap the previous operation when switching to reference', () => {
@ -963,7 +1066,7 @@ describe('state_helpers', () => {
isTransferable: jest.fn(),
toExpression: jest.fn().mockReturnValue([]),
getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }),
getDefaultLabel: () => 'Test reference',
getDefaultLabel: jest.fn().mockReturnValue('Test reference'),
};
const layer: IndexPatternLayer = {
@ -1081,6 +1184,7 @@ describe('state_helpers', () => {
},
},
columnId: 'col1',
indexPattern,
})
).toEqual({
indexPatternId: '1',
@ -1126,6 +1230,7 @@ describe('state_helpers', () => {
},
},
columnId: 'col2',
indexPattern,
})
).toEqual({
indexPatternId: '1',
@ -1176,11 +1281,14 @@ describe('state_helpers', () => {
},
},
columnId: 'col2',
indexPattern,
});
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, {
col1: termsColumn,
});
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(
{ indexPatternId: '1', columnOrder: ['col1', 'col2'], columns: { col1: termsColumn } },
'col1',
'col2'
);
});
it('should delete the column and all of its references', () => {
@ -1207,11 +1315,57 @@ describe('state_helpers', () => {
},
},
};
expect(deleteColumn({ layer, columnId: 'col2' })).toEqual(
expect(deleteColumn({ layer, columnId: 'col2', indexPattern })).toEqual(
expect.objectContaining({ columnOrder: [], columns: {} })
);
});
it('should update the labels when deleting columns', () => {
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
label: 'Count',
dataType: 'number',
isBucketed: false,
operationType: 'count',
sourceField: 'Records',
},
col2: {
label: 'Changed label',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: ['col1'],
},
},
};
deleteColumn({ layer, columnId: 'col1', indexPattern });
expect(operationDefinitionMap.testReference.getDefaultLabel).toHaveBeenCalledWith(
{
label: 'Changed label',
dataType: 'number',
isBucketed: false,
operationType: 'testReference',
references: ['col1'],
},
indexPattern,
{
col2: {
label: 'Default label',
dataType: 'number',
isBucketed: false,
operationType: 'testReference',
references: ['col1'],
},
}
);
});
it('should recursively delete references', () => {
const layer: IndexPatternLayer = {
indexPatternId: '1',
@ -1245,7 +1399,7 @@ describe('state_helpers', () => {
},
},
};
expect(deleteColumn({ layer, columnId: 'col3' })).toEqual(
expect(deleteColumn({ layer, columnId: 'col3', indexPattern })).toEqual(
expect.objectContaining({ columnOrder: [], columns: {} })
);
});
@ -1680,7 +1834,22 @@ describe('state_helpers', () => {
});
describe('getErrorMessages', () => {
it('should collect errors from the operation definitions', () => {
it('should collect errors from metric-type operation definitions', () => {
const mock = jest.fn().mockReturnValue(['error 1']);
operationDefinitionMap.avg.getErrorMessage = mock;
const errors = getErrorMessages({
indexPatternId: '1',
columnOrder: [],
columns: {
// @ts-expect-error invalid column
col1: { operationType: 'avg' },
},
});
expect(mock).toHaveBeenCalled();
expect(errors).toHaveLength(1);
});
it('should collect errors from reference-type operation definitions', () => {
const mock = jest.fn().mockReturnValue(['error 1']);
operationDefinitionMap.testReference.getErrorMessage = mock;
const errors = getErrorMessages({
@ -1695,49 +1864,5 @@ describe('state_helpers', () => {
expect(mock).toHaveBeenCalled();
expect(errors).toHaveLength(1);
});
it('should identify missing references', () => {
const errors = getErrorMessages({
indexPatternId: '1',
columnOrder: [],
columns: {
col1:
// @ts-expect-error not statically analyzed yet
{ operationType: 'testReference', references: ['ref1', 'ref2'] },
},
});
expect(errors).toHaveLength(2);
});
it('should identify references that are no longer valid', () => {
// There is only one operation with `none` as the input type
// @ts-expect-error this function is not valid
operationDefinitionMap.testReference.requiredReferences = [
{
input: ['none'],
validateMetadata: () => true,
},
];
const errors = getErrorMessages({
indexPatternId: '1',
columnOrder: [],
columns: {
// @ts-expect-error incomplete operation
ref1: {
dataType: 'string',
isBucketed: true,
operationType: 'terms',
},
col1: {
label: '',
references: ['ref1'],
// @ts-expect-error tests only
operationType: 'testReference',
},
},
});
expect(errors).toHaveLength(1);
});
});
});

View file

@ -5,7 +5,6 @@
*/
import _, { partition } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
operationDefinitionMap,
operationDefinitions,
@ -61,9 +60,15 @@ export function insertNewColumn({
const possibleOperation = operationDefinition.getPossibleOperation();
const isBucketed = Boolean(possibleOperation.isBucketed);
if (isBucketed) {
return addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId);
return updateDefaultLabels(
addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId),
indexPattern
);
} else {
return addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId);
return updateDefaultLabels(
addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId),
indexPattern
);
}
}
@ -77,7 +82,7 @@ export function insertNewColumn({
// access to the operationSupportMatrix, we should validate the metadata against
// the possible fields
const validOperations = Object.values(operationDefinitionMap).filter(({ type }) =>
isOperationAllowedAsReference({ validation, operationType: type })
isOperationAllowedAsReference({ validation, operationType: type, indexPattern })
);
if (!validOperations.length) {
@ -122,29 +127,23 @@ export function insertNewColumn({
return newId;
});
const possibleOperation = operationDefinition.getPossibleOperation();
const isBucketed = Boolean(possibleOperation.isBucketed);
if (isBucketed) {
return addBucket(
tempLayer,
operationDefinition.buildColumn({
...baseOptions,
layer: tempLayer,
referenceIds,
}),
columnId
);
} else {
return addMetric(
tempLayer,
operationDefinition.buildColumn({
...baseOptions,
layer: tempLayer,
referenceIds,
}),
columnId
const possibleOperation = operationDefinition.getPossibleOperation(indexPattern);
if (!possibleOperation) {
throw new Error(
`Can't create operation ${op} because it's incompatible with the index pattern`
);
}
const isBucketed = Boolean(possibleOperation.isBucketed);
const addOperationFn = isBucketed ? addBucket : addMetric;
return updateDefaultLabels(
addOperationFn(
tempLayer,
operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, referenceIds }),
columnId
),
indexPattern
);
}
const invalidFieldName = (layer.incompleteColumns ?? {})[columnId]?.sourceField;
@ -159,16 +158,22 @@ export function insertNewColumn({
}
const isBucketed = Boolean(possibleOperation.isBucketed);
if (isBucketed) {
return addBucket(
layer,
operationDefinition.buildColumn({ ...baseOptions, layer, field: invalidField }),
columnId
return updateDefaultLabels(
addBucket(
layer,
operationDefinition.buildColumn({ ...baseOptions, layer, field: invalidField }),
columnId
),
indexPattern
);
} else {
return addMetric(
layer,
operationDefinition.buildColumn({ ...baseOptions, layer, field: invalidField }),
columnId
return updateDefaultLabels(
addMetric(
layer,
operationDefinition.buildColumn({ ...baseOptions, layer, field: invalidField }),
columnId
),
indexPattern
);
}
} else if (!field) {
@ -193,19 +198,15 @@ export function insertNewColumn({
};
}
const isBucketed = Boolean(possibleOperation.isBucketed);
if (isBucketed) {
return addBucket(
const addOperationFn = isBucketed ? addBucket : addMetric;
return updateDefaultLabels(
addOperationFn(
layer,
operationDefinition.buildColumn({ ...baseOptions, layer, field }),
columnId
);
} else {
return addMetric(
layer,
operationDefinition.buildColumn({ ...baseOptions, layer, field }),
columnId
);
}
),
indexPattern
);
}
export function replaceColumn({
@ -241,39 +242,50 @@ export function replaceColumn({
if (previousDefinition.input === 'fullReference') {
(previousColumn as ReferenceBasedIndexPatternColumn).references.forEach((id: string) => {
tempLayer = deleteColumn({ layer: tempLayer, columnId: id });
tempLayer = deleteColumn({ layer: tempLayer, columnId: id, indexPattern });
});
}
tempLayer = resetIncomplete(tempLayer, columnId);
if (operationDefinition.input === 'fullReference') {
const referenceIds = operationDefinition.requiredReferences.map(() => generateId());
const newColumns = {
...tempLayer.columns,
[columnId]: operationDefinition.buildColumn({
...baseOptions,
layer: tempLayer,
referenceIds,
previousColumn,
}),
};
return {
const newLayer = {
...tempLayer,
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
columns: newColumns,
columns: {
...tempLayer.columns,
[columnId]: operationDefinition.buildColumn({
...baseOptions,
layer: tempLayer,
referenceIds,
previousColumn,
}),
},
};
return updateDefaultLabels(
{
...tempLayer,
columnOrder: getColumnOrder(newLayer),
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
},
indexPattern
);
}
if (operationDefinition.input === 'none') {
let newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer });
newColumn = adjustLabel(newColumn, previousColumn);
const newColumns = { ...tempLayer.columns, [columnId]: newColumn };
return {
...tempLayer,
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
columns: adjustColumnReferencesForChangedColumn(newColumns, columnId),
};
const newLayer = { ...tempLayer, columns: { ...tempLayer.columns, [columnId]: newColumn } };
return updateDefaultLabels(
{
...tempLayer,
columnOrder: getColumnOrder(newLayer),
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
},
indexPattern
);
}
if (!field) {
@ -289,12 +301,15 @@ export function replaceColumn({
let newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, field });
newColumn = adjustLabel(newColumn, previousColumn);
const newColumns = { ...tempLayer.columns, [columnId]: newColumn };
return {
...tempLayer,
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
columns: adjustColumnReferencesForChangedColumn(newColumns, columnId),
};
const newLayer = { ...tempLayer, columns: { ...tempLayer.columns, [columnId]: newColumn } };
return updateDefaultLabels(
{
...tempLayer,
columnOrder: getColumnOrder(newLayer),
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
},
indexPattern
);
} else if (
operationDefinition.input === 'field' &&
field &&
@ -304,12 +319,20 @@ export function replaceColumn({
// Same operation, new field
const newColumn = operationDefinition.onFieldChange(previousColumn, field);
const newColumns = { ...layer.columns, [columnId]: adjustLabel(newColumn, previousColumn) };
return {
...layer,
columnOrder: getColumnOrder({ ...layer, columns: newColumns }),
columns: adjustColumnReferencesForChangedColumn(newColumns, columnId),
};
if (previousColumn.customLabel) {
newColumn.customLabel = true;
newColumn.label = previousColumn.label;
}
const newLayer = { ...layer, columns: { ...layer.columns, [columnId]: newColumn } };
return updateDefaultLabels(
{
...resetIncomplete(layer, columnId),
columnOrder: getColumnOrder(newLayer),
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
},
indexPattern
);
} else {
throw new Error('nothing changed');
}
@ -370,7 +393,6 @@ function addMetric(
...layer.columns,
[addedColumnId]: column,
},
columnOrder: [...layer.columnOrder, addedColumnId],
};
return { ...tempLayer, columnOrder: getColumnOrder(tempLayer) };
}
@ -409,17 +431,18 @@ export function updateColumnParam<C extends IndexPatternColumn>({
};
}
function adjustColumnReferencesForChangedColumn(
columns: Record<string, IndexPatternColumn>,
columnId: string
) {
const newColumns = { ...columns };
function adjustColumnReferencesForChangedColumn(layer: IndexPatternLayer, changedColumnId: string) {
const newColumns = { ...layer.columns };
Object.keys(newColumns).forEach((currentColumnId) => {
if (currentColumnId !== columnId) {
if (currentColumnId !== changedColumnId) {
const currentColumn = newColumns[currentColumnId];
const operationDefinition = operationDefinitionMap[currentColumn.operationType];
newColumns[currentColumnId] = operationDefinition.onOtherColumnChanged
? operationDefinition.onOtherColumnChanged(currentColumn, newColumns)
? operationDefinition.onOtherColumnChanged(
{ ...layer, columns: newColumns },
currentColumnId,
changedColumnId
)
: currentColumn;
}
});
@ -429,9 +452,11 @@ function adjustColumnReferencesForChangedColumn(
export function deleteColumn({
layer,
columnId,
indexPattern,
}: {
layer: IndexPatternLayer;
columnId: string;
indexPattern: IndexPattern;
}): IndexPatternLayer {
const column = layer.columns[columnId];
if (!column) {
@ -451,17 +476,27 @@ export function deleteColumn({
let newLayer = {
...layer,
columns: adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId),
columns: adjustColumnReferencesForChangedColumn(
{ ...layer, columns: hypotheticalColumns },
columnId
),
};
extraDeletions.forEach((id) => {
newLayer = deleteColumn({ layer: newLayer, columnId: id });
newLayer = deleteColumn({ layer: newLayer, columnId: id, indexPattern });
});
const newIncomplete = { ...(newLayer.incompleteColumns || {}) };
delete newIncomplete[columnId];
return { ...newLayer, columnOrder: getColumnOrder(newLayer), incompleteColumns: newIncomplete };
return updateDefaultLabels(
{
...newLayer,
columnOrder: getColumnOrder(newLayer),
incompleteColumns: newIncomplete,
},
indexPattern
);
}
// Derives column order from column object, respects existing columnOrder
@ -482,7 +517,7 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] {
const [direct, referenceBased] = _.partition(
entries,
([id, col]) => operationDefinitionMap[col.operationType].input !== 'fullReference'
([, col]) => operationDefinitionMap[col.operationType].input !== 'fullReference'
);
// If a reference has another reference as input, put it last in sort order
referenceBased.sort(([idA, a], [idB, b]) => {
@ -503,7 +538,7 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] {
}
// Splits existing columnOrder into the three categories
function getExistingColumnGroups(layer: IndexPatternLayer): [string[], string[], string[]] {
export function getExistingColumnGroups(layer: IndexPatternLayer): [string[], string[], string[]] {
const [direct, referenced] = partition(
layer.columnOrder,
(columnId) => layer.columns[columnId] && !('references' in layer.columns[columnId])
@ -553,44 +588,9 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined
Object.entries(layer.columns).forEach(([columnId, column]) => {
const def = operationDefinitionMap[column.operationType];
if (def.input === 'fullReference' && def.getErrorMessage) {
if (def.getErrorMessage) {
errors.push(...(def.getErrorMessage(layer, columnId) ?? []));
}
if ('references' in column) {
column.references.forEach((referenceId, index) => {
if (!layer.columns[referenceId]) {
errors.push(
i18n.translate('xpack.lens.indexPattern.missingReferenceError', {
defaultMessage: 'Dimension {dimensionLabel} is incomplete',
values: {
dimensionLabel: column.label,
},
})
);
} else {
const referenceColumn = layer.columns[referenceId]!;
const requirements =
// @ts-expect-error not statically analyzed
operationDefinitionMap[column.operationType].requiredReferences[index];
const isValid = isColumnValidAsReference({
validation: requirements,
column: referenceColumn,
});
if (!isValid) {
errors.push(
i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', {
defaultMessage: 'Dimension {dimensionLabel} does not have a valid configuration',
values: {
dimensionLabel: column.label,
},
})
);
}
}
});
}
});
return errors.length ? errors : undefined;
@ -603,30 +603,15 @@ export function isReferenced(layer: IndexPatternLayer, columnId: string): boolea
return allReferences.includes(columnId);
}
function isColumnValidAsReference({
column,
validation,
}: {
column: IndexPatternColumn;
validation: RequiredReference;
}): boolean {
if (!column) return false;
const operationType = column.operationType;
const operationDefinition = operationDefinitionMap[operationType];
return (
validation.input.includes(operationDefinition.input) &&
(!validation.specificOperations || validation.specificOperations.includes(operationType)) &&
validation.validateMetadata(column)
);
}
function isOperationAllowedAsReference({
export function isOperationAllowedAsReference({
operationType,
validation,
field,
indexPattern,
}: {
operationType: OperationType;
validation: RequiredReference;
indexPattern: IndexPattern;
field?: IndexPatternField;
}): boolean {
const operationDefinition = operationDefinitionMap[operationType];
@ -635,9 +620,12 @@ function isOperationAllowedAsReference({
if (field && operationDefinition.input === 'field') {
const metadata = operationDefinition.getPossibleOperationForField(field);
hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!);
} else if (operationDefinition.input !== 'field') {
} else if (operationDefinition.input === 'none') {
const metadata = operationDefinition.getPossibleOperation();
hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!);
} else if (operationDefinition.input === 'fullReference') {
const metadata = operationDefinition.getPossibleOperation(indexPattern);
hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!);
} else {
// TODO: How can we validate the metadata without a specific field?
}
@ -648,6 +636,29 @@ function isOperationAllowedAsReference({
);
}
// Labels need to be updated when columns are added because reference-based column labels
// are sometimes copied into the parents
function updateDefaultLabels(
layer: IndexPatternLayer,
indexPattern: IndexPattern
): IndexPatternLayer {
const copiedColumns = { ...layer.columns };
layer.columnOrder.forEach((id) => {
const col = copiedColumns[id];
if (!col.customLabel) {
copiedColumns[id] = {
...col,
label: operationDefinitionMap[col.operationType].getDefaultLabel(
col,
indexPattern,
copiedColumns
),
};
}
});
return { ...layer, columns: copiedColumns };
}
export function resetIncomplete(layer: IndexPatternLayer, columnId: string): IndexPatternLayer {
const incompleteColumns = { ...(layer.incompleteColumns ?? {}) };
delete incompleteColumns[columnId];

View file

@ -167,10 +167,13 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) {
operationDefinition.getPossibleOperation()
);
} else if (operationDefinition.input === 'fullReference') {
addToMap(
{ type: 'fullReference', operationType: operationDefinition.type },
operationDefinition.getPossibleOperation()
);
const validOperation = operationDefinition.getPossibleOperation(indexPattern);
if (validOperation) {
addToMap(
{ type: 'fullReference', operationType: operationDefinition.type },
validOperation
);
}
}
});

View file

@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { TimeScaleUnit } from '../time_scale';
import { IndexPatternColumn } from './definitions';
import type { IndexPatternLayer } from '../types';
import type { TimeScaleUnit } from '../time_scale';
import type { IndexPatternColumn } from './definitions';
import { adjustTimeScaleLabelSuffix, adjustTimeScaleOnOtherColumnChange } from './time_scale_utils';
export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit;
@ -48,45 +49,71 @@ describe('time scale utils', () => {
isBucketed: false,
timeScale: 's',
};
const baseLayer: IndexPatternLayer = {
columns: { col1: baseColumn },
columnOrder: [],
indexPatternId: '',
};
it('should keep column if there is no time scale', () => {
const column = { ...baseColumn, timeScale: undefined };
expect(adjustTimeScaleOnOtherColumnChange(column, { col1: column })).toBe(column);
expect(
adjustTimeScaleOnOtherColumnChange(
{ ...baseLayer, columns: { col1: column } },
'col1',
'col2'
)
).toBe(column);
});
it('should keep time scale if there is a date histogram', () => {
expect(
adjustTimeScaleOnOtherColumnChange(baseColumn, {
col1: baseColumn,
col2: {
operationType: 'date_histogram',
dataType: 'date',
isBucketed: true,
label: '',
adjustTimeScaleOnOtherColumnChange(
{
...baseLayer,
columns: {
col1: baseColumn,
col2: {
operationType: 'date_histogram',
dataType: 'date',
isBucketed: true,
label: '',
sourceField: 'date',
params: { interval: 'auto' },
},
},
},
})
'col1',
'col2'
)
).toBe(baseColumn);
});
it('should remove time scale if there is no date histogram', () => {
expect(adjustTimeScaleOnOtherColumnChange(baseColumn, { col1: baseColumn })).toHaveProperty(
expect(adjustTimeScaleOnOtherColumnChange(baseLayer, 'col1', 'col2')).toHaveProperty(
'timeScale',
undefined
);
});
it('should remove suffix from label', () => {
expect(adjustTimeScaleOnOtherColumnChange(baseColumn, { col1: baseColumn })).toHaveProperty(
'label',
'Count of records'
);
expect(
adjustTimeScaleOnOtherColumnChange(
{ ...baseLayer, columns: { col1: baseColumn } },
'col1',
'col2'
)
).toHaveProperty('label', 'Count of records');
});
it('should keep custom label', () => {
const column = { ...baseColumn, label: 'abc', customLabel: true };
expect(adjustTimeScaleOnOtherColumnChange(column, { col1: column })).toHaveProperty(
'label',
'abc'
);
expect(
adjustTimeScaleOnOtherColumnChange(
{ ...baseLayer, columns: { col1: column } },
'col1',
'col2'
)
).toHaveProperty('label', 'abc');
});
});
});

View file

@ -5,8 +5,9 @@
*/
import { unitSuffixesLong } from '../suffix_formatter';
import { TimeScaleUnit } from '../time_scale';
import { BaseIndexPatternColumn } from './definitions/column_types';
import type { TimeScaleUnit } from '../time_scale';
import type { IndexPatternLayer } from '../types';
import type { IndexPatternColumn } from './definitions';
export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit;
@ -30,10 +31,13 @@ export function adjustTimeScaleLabelSuffix(
return `${cleanedLabel} ${unitSuffixesLong[newTimeScale]}`;
}
export function adjustTimeScaleOnOtherColumnChange<T extends BaseIndexPatternColumn>(
column: T,
columns: Partial<Record<string, BaseIndexPatternColumn>>
) {
export function adjustTimeScaleOnOtherColumnChange<T extends IndexPatternColumn>(
layer: IndexPatternLayer,
thisColumnId: string,
changedColumnId: string
): T {
const columns = layer.columns;
const column = columns[thisColumnId] as T;
if (!column.timeScale) {
return column;
}

View file

@ -5,7 +5,7 @@
*/
import { DataType } from '../types';
import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from './types';
import { IndexPattern, IndexPatternLayer } from './types';
import { DraggedField } from './indexpattern';
import {
BaseIndexPatternColumn,
@ -44,29 +44,6 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg
);
}
export function hasInvalidColumns(state: IndexPatternPrivateState) {
return getInvalidLayers(state).length > 0;
}
export function getInvalidLayers(state: IndexPatternPrivateState) {
return Object.values(state.layers).filter((layer) => {
return layer.columnOrder.some((columnId) =>
isColumnInvalid(layer, columnId, state.indexPatterns[layer.indexPatternId])
);
});
}
export function getInvalidColumnsForLayer(
layers: IndexPatternLayer[],
indexPatternMap: Record<string, IndexPattern>
) {
return layers.map((layer) => {
return layer.columnOrder.filter((columnId) =>
isColumnInvalid(layer, columnId, indexPatternMap[layer.indexPatternId])
);
});
}
export function isColumnInvalid(
layer: IndexPatternLayer,
columnId: string,

View file

@ -326,6 +326,81 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await PageObjects.lens.getDatatableCellText(0, 1)).to.eql('6,011.351');
});
it('should create a valid XY chart with references', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');
await PageObjects.lens.goToTimeRange();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
operation: 'date_histogram',
field: '@timestamp',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'moving_average',
keepOpen: true,
});
await PageObjects.lens.configureReference({
operation: 'sum',
field: 'bytes',
});
await PageObjects.lens.closeDimensionEditor();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'cumulative_sum',
keepOpen: true,
});
await PageObjects.lens.configureReference({
field: 'Records',
});
await PageObjects.lens.closeDimensionEditor();
// Two Y axes that are both valid
expect(await find.allByCssSelector('.echLegendItem')).to.have.length(2);
});
/**
* The edge cases are:
*
* 1. Showing errors when creating a partial configuration
* 2. Being able to drag in a new field while in partial config
* 3. Being able to switch charts while in partial config
*/
it('should handle edge cases in reference-based operations', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');
await PageObjects.lens.goToTimeRange();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
operation: 'date_histogram',
field: '@timestamp',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'cumulative_sum',
});
expect(await PageObjects.lens.getErrorCount()).to.eql(1);
await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel');
expect(await PageObjects.lens.getErrorCount()).to.eql(2);
await PageObjects.lens.dragFieldToDimensionTrigger(
'@timestamp',
'lnsXY_xDimensionPanel > lns-empty-dimension'
);
expect(await PageObjects.lens.getErrorCount()).to.eql(1);
expect(await PageObjects.lens.hasChartSwitchWarning('lnsDatatable')).to.eql(false);
await PageObjects.lens.switchToVisualization('lnsDatatable');
expect(await PageObjects.lens.getDimensionTriggerText('lnsDatatable_metrics')).to.eql(
'Cumulative sum of (incomplete)'
);
});
it('should allow to change index pattern', async () => {
await PageObjects.lens.switchFirstLayerIndexPattern('log*');
expect(await PageObjects.lens.getFirstLayerIndexPattern()).to.equal('log*');

View file

@ -122,6 +122,32 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
}
},
/**
* Changes the specified dimension to the specified operation and (optinally) field.
*
* @param opts.dimension - the selector of the dimension being changed
* @param opts.operation - the desired operation ID for the dimension
* @param opts.field - the desired field for the dimension
* @param layerIndex - the index of the layer
*/
async configureReference(opts: {
operation?: string;
field?: string;
isPreviousIncompatible?: boolean;
}) {
if (opts.operation) {
const target = await testSubjects.find('indexPattern-subFunction-selection-row');
await comboBox.openOptionsList(target);
await comboBox.setElement(target, opts.operation);
}
if (opts.field) {
const target = await testSubjects.find('indexPattern-reference-field-selection-row');
await comboBox.openOptionsList(target);
await comboBox.setElement(target, opts.field);
}
},
/**
* Drags field to workspace
*
@ -327,6 +353,19 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
});
},
/** Counts the visible warnings in the config panel */
async getErrorCount() {
const moreButton = await testSubjects.exists('configuration-failure-more-errors');
if (moreButton) {
await retry.try(async () => {
await testSubjects.click('configuration-failure-more-errors');
await testSubjects.missingOrFail('configuration-failure-more-errors');
});
}
const errors = await testSubjects.findAll('configuration-failure-error');
return errors?.length ?? 0;
},
/**
* Checks a specific subvisualization in the chart switcher for a "data loss" indicator
*