[Lens] Transitions for reference-based operations (#83348) (#87724)

* [Lens] Transition between functions involving references

* Organize transition cases and cover all the basic transitions

* Add functional test

* Change logic for displaying valid transitions

* Show valid transitions more accurately

* Fix transition to only consider valid outputs

* Update test names and style

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Wylie Conlon 2021-01-07 17:53:17 -05:00 committed by GitHub
parent 646bdb1342
commit 1bb1c030f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1035 additions and 292 deletions

View file

@ -30,6 +30,7 @@ import {
updateColumnParam,
resetIncomplete,
FieldBasedIndexPatternColumn,
canTransition,
} from '../operations';
import { mergeLayer } from '../state_helpers';
import { FieldSelect } from './field_select';
@ -147,15 +148,20 @@ export function DimensionEditor(props: DimensionEditorProps) {
const operationsWithCompatibility = [...possibleOperations].map((operationType) => {
const definition = operationDefinitionMap[operationType];
const currentField =
selectedColumn &&
hasField(selectedColumn) &&
currentIndexPattern.getFieldByName(selectedColumn.sourceField);
return {
operationType,
compatibleWithCurrentField:
!selectedColumn ||
(selectedColumn &&
hasField(selectedColumn) &&
definition.input === 'field' &&
fieldByOperation[operationType]?.has(selectedColumn.sourceField)) ||
(selectedColumn && !hasField(selectedColumn) && definition.input === 'none'),
compatibleWithCurrentField: canTransition({
layer: state.layers[layerId],
columnId,
op: operationType,
indexPattern: currentIndexPattern,
field: currentField || undefined,
filterOperations: props.filterOperations,
}),
disabledStatus:
definition.getDisabledStatus &&
definition.getDisabledStatus(

View file

@ -337,17 +337,124 @@ describe('IndexPatternDimensionEditorPanel', () => {
const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || [];
expect(items.find(({ label }) => label === 'Minimum')!['data-test-subj']).not.toContain(
expect(items.find(({ id }) => id === 'min')!['data-test-subj']).not.toContain('incompatible');
expect(items.find(({ id }) => id === 'date_histogram')!['data-test-subj']).toContain(
'incompatible'
);
// Incompatible because there is no date field
expect(items.find(({ id }) => id === 'cumulative_sum')!['data-test-subj']).toContain(
'incompatible'
);
expect(items.find(({ label }) => label === 'Date histogram')!['data-test-subj']).toContain(
expect(items.find(({ id }) => id === 'filters')!['data-test-subj']).not.toContain(
'incompatible'
);
});
it('should indicate when a transition is invalid due to filterOperations', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={getStateWithColumns({
col1: {
label: 'Unique count of source',
dataType: 'number',
isBucketed: false,
operationType: 'cardinality',
sourceField: 'source,',
},
})}
filterOperations={(meta) => meta.dataType === 'number' && !meta.isBucketed}
/>
);
const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || [];
expect(items.find(({ id }) => id === 'min')!['data-test-subj']).toContain('incompatible');
expect(items.find(({ id }) => id === 'cumulative_sum')!['data-test-subj']).toContain(
'incompatible'
);
});
it('should indicate that reference-based operations are not compatible when they are incomplete', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={getStateWithColumns({
date: {
label: 'Date',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: '@timestamp',
params: { interval: 'auto' },
},
col1: {
label: 'Counter rate',
dataType: 'number',
isBucketed: false,
operationType: 'counter_rate',
references: ['ref'],
},
})}
/>
);
const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || [];
expect(items.find(({ id }) => id === 'derivative')!['data-test-subj']).toContain(
'incompatible'
);
expect(items.find(({ id }) => id === 'cumulative_sum')!['data-test-subj']).toContain(
'incompatible'
);
expect(items.find(({ id }) => id === 'moving_average')!['data-test-subj']).toContain(
'incompatible'
);
});
it('should indicate that reference-based operations are compatible sometimes', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={getStateWithColumns({
date: {
label: 'Date',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: '@timestamp',
params: { interval: 'auto' },
},
col1: {
label: 'Cumulative sum',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['ref'],
},
ref: {
label: 'Count',
dataType: 'number',
isBucketed: false,
operationType: 'count',
sourceField: 'Records',
},
})}
/>
);
const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || [];
expect(items.find(({ id }) => id === 'counter_rate')!['data-test-subj']).toContain(
'incompatible'
);
// Fieldless operation is compatible with field
expect(items.find(({ label }) => label === 'Filters')!['data-test-subj']).toContain(
'compatible'
expect(items.find(({ id }) => id === 'derivative')!['data-test-subj']).not.toContain(
'incompatible'
);
expect(items.find(({ id }) => id === 'moving_average')!['data-test-subj']).not.toContain(
'incompatible'
);
});
@ -640,9 +747,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
.find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]')
.simulate('click');
wrapper
.find('button[data-test-subj="lns-indexPatternDimension-filters incompatible"]')
.simulate('click');
wrapper.find('button[data-test-subj="lns-indexPatternDimension-filters"]').simulate('click');
expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0);
});
@ -1623,7 +1728,15 @@ describe('IndexPatternDimensionEditorPanel', () => {
id: '1',
title: 'my-fake-index-pattern',
hasRestrictions: false,
fields,
fields: [
{
name: 'bytes',
displayName: 'bytes',
type: 'number',
aggregatable: true,
searchable: true,
},
],
getFieldByName: getFieldByNameFactory([
{
name: 'bytes',

View file

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

View file

@ -10,8 +10,8 @@ import type { TimeScaleUnit } from '../../../time_scale';
import type { IndexPattern, IndexPatternLayer } from '../../../types';
import { adjustTimeScaleLabelSuffix } from '../../time_scale_utils';
import type { ReferenceBasedIndexPatternColumn } from '../column_types';
import { isColumnValidAsReference } from '../../layer_helpers';
import { operationDefinitionMap } from '..';
import type { IndexPatternColumn, RequiredReference } from '..';
export const buildLabelFunction = (ofName: (name?: string) => string) => (
name?: string,
@ -85,23 +85,6 @@ export function checkReferences(layer: IndexPatternLayer, columnId: string) {
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,

View file

@ -864,7 +864,7 @@ describe('state_helpers', () => {
columns: {
col1: termsColumn,
willBeReference: {
label: 'Count',
label: 'Count of records',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
@ -878,14 +878,18 @@ describe('state_helpers', () => {
});
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(
{
indexPatternId: '1',
columnOrder: ['col1', 'willBeReference'],
expect.objectContaining({
columns: {
col1: {
...termsColumn,
params: { orderBy: { type: 'alphabetical' }, orderDirection: 'asc', size: 5 },
},
id1: expect.objectContaining({
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
}),
willBeReference: expect.objectContaining({
dataType: 'number',
isBucketed: false,
@ -893,225 +897,531 @@ describe('state_helpers', () => {
}),
},
incompleteColumns: {},
},
}),
'col1',
'willBeReference'
);
});
it('should not wrap the previous operation when switching to reference', () => {
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Count',
customLabel: true,
dataType: 'number' as const,
isBucketed: false,
sourceField: 'Records',
operationType: 'count' as const,
},
},
};
const result = replaceColumn({
layer,
indexPattern,
columnId: 'col1',
op: 'testReference' as OperationType,
describe('switching from non-reference to reference test cases', () => {
it('should wrap around the previous operation as a reference if possible (case new1)', () => {
const expectedColumn = {
label: 'Count',
customLabel: true,
dataType: 'number' as const,
isBucketed: false,
sourceField: 'Records',
operationType: 'count' as const,
};
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1'],
columns: { col1: expectedColumn },
};
const result = replaceColumn({
layer,
indexPattern,
columnId: 'col1',
op: 'testReference' as OperationType,
});
expect(operationDefinitionMap.testReference.buildColumn).toHaveBeenCalledWith(
expect.objectContaining({
referenceIds: ['id1'],
})
);
expect(result.columnOrder).toEqual(['id1', 'col1']);
expect(result.columns).toEqual(
expect.objectContaining({
id1: expectedColumn,
col1: expect.any(Object),
})
);
});
expect(operationDefinitionMap.testReference.buildColumn).toHaveBeenCalledWith(
expect.objectContaining({
referenceIds: ['id1'],
})
);
expect(result.columns).toEqual(
expect.objectContaining({
col1: expect.objectContaining({ operationType: 'testReference' }),
})
);
});
it('should delete the previous references and reset to default values when going from reference to no-input', () => {
// @ts-expect-error this function is not valid
operationDefinitionMap.testReference.requiredReferences = [
{
input: ['none'],
validateMetadata: () => true,
},
];
const expectedCol = {
dataType: 'string' as const,
isBucketed: true,
operationType: 'filters' as const,
params: {
// These filters are reset
filters: [{ input: { query: 'field: true', language: 'kuery' }, label: 'Custom label' }],
},
};
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
...expectedCol,
label: 'Custom label',
customLabel: true,
it('should create a new no-input operation to use as reference (case new2)', () => {
// @ts-expect-error this function is not valid
operationDefinitionMap.testReference.requiredReferences = [
{
input: ['none'],
validateMetadata: () => true,
},
col2: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: ['col1'],
},
},
};
expect(
replaceColumn({
layer,
indexPattern,
columnId: 'col2',
op: 'filters',
})
).toEqual(
expect.objectContaining({
columnOrder: ['col2'],
];
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col2: {
...expectedCol,
label: 'Filters',
scale: 'ordinal', // added in buildColumn
params: {
filters: [{ input: { query: '', language: 'kuery' }, label: '' }],
},
col1: {
label: 'Avg',
dataType: 'number' as const,
isBucketed: false,
sourceField: 'bytes',
operationType: 'avg' as const,
},
},
})
);
});
it('should delete the inner references when switching away from reference to field-based operation', () => {
const expectedCol = {
label: 'Count of records',
dataType: 'number' as const,
isBucketed: false,
operationType: 'count' as const,
sourceField: 'Records',
};
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: expectedCol,
col2: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: ['col1'],
},
},
};
expect(
replaceColumn({
};
const result = replaceColumn({
layer,
indexPattern,
columnId: 'col2',
op: 'count',
field: documentField,
})
).toEqual(
expect.objectContaining({
columnOrder: ['col2'],
columns: {
col2: expect.objectContaining(expectedCol),
},
})
);
});
columnId: 'col1',
// @ts-expect-error
op: 'testReference',
});
it('should reset when switching from one reference to another', () => {
operationDefinitionMap.secondTest = {
input: 'fullReference',
displayName: 'Reference test 2',
// @ts-expect-error this type is not statically available
type: 'secondTest',
requiredReferences: [
expect(result.columnOrder).toEqual(['id1', 'col1']);
expect(result.columns).toEqual({
id1: expect.objectContaining({
operationType: 'filters',
}),
col1: expect.objectContaining({
operationType: 'testReference',
}),
});
});
it('should use the previous field, but select the best operation, when creating a reference (case new3)', () => {
// @ts-expect-error this function is not valid
operationDefinitionMap.testReference.requiredReferences = [
{
// Any numeric metric that isn't also a reference
input: ['none', 'field'],
validateMetadata: (meta: OperationMetadata) =>
meta.dataType === 'number' && !meta.isBucketed,
input: ['field'],
validateMetadata: () => true,
specificOperations: ['cardinality', 'sum', 'avg'], // this order is ignored
},
],
// @ts-expect-error don't want to define valid arguments
buildColumn: jest.fn((args) => {
return {
label: 'Test reference',
isBucketed: false,
dataType: 'number',
operationType: 'secondTest',
references: args.referenceIds,
};
}),
isTransferable: jest.fn(),
toExpression: jest.fn().mockReturnValue([]),
getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }),
getDefaultLabel: jest.fn().mockReturnValue('Test reference'),
};
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
label: 'Count',
customLabel: true,
dataType: 'number' as const,
isBucketed: false,
operationType: 'count' as const,
sourceField: 'Records',
];
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Max',
dataType: 'number' as const,
isBucketed: false,
sourceField: 'bytes',
operationType: 'max' as const,
},
},
col2: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
};
const result = replaceColumn({
layer,
indexPattern,
columnId: 'col1',
// @ts-expect-error test only
op: 'testReference',
});
// @ts-expect-error not a valid type
expect(result.columnOrder).toEqual(['id1', 'col1']);
expect(result.columns).toEqual({
id1: expect.objectContaining({
operationType: 'avg',
}),
col1: expect.objectContaining({
operationType: 'testReference',
references: ['col1'],
}),
});
});
it('should ignore previous field and previous operation, but set incomplete operation if known (case new4)', () => {
// @ts-expect-error this function is not valid
operationDefinitionMap.testReference.requiredReferences = [
{
input: ['field'],
validateMetadata: () => true,
specificOperations: ['cardinality'],
},
},
};
expect(
];
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Count',
dataType: 'number' as const,
isBucketed: false,
sourceField: 'Records',
operationType: 'count' as const,
},
},
};
const result = replaceColumn({
layer,
indexPattern,
columnId: 'col1',
// @ts-expect-error
op: 'testReference',
});
expect(result.incompleteColumns).toEqual({
id1: { operationType: 'cardinality' },
});
expect(result.columns).toEqual({
col1: expect.objectContaining({
operationType: 'testReference',
}),
});
});
it('should leave an empty reference if all the other cases fail (case new6)', () => {
// @ts-expect-error this function is not valid
operationDefinitionMap.testReference.requiredReferences = [
{
input: ['field'],
validateMetadata: () => false,
specificOperations: [],
},
];
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Count',
dataType: 'number' as const,
isBucketed: false,
sourceField: 'Records',
operationType: 'count' as const,
},
},
};
const result = replaceColumn({
layer,
indexPattern,
columnId: 'col1',
// @ts-expect-error
op: 'testReference',
});
expect(result.incompleteColumns).toEqual({});
expect(result.columns).toEqual({
col1: expect.objectContaining({
operationType: 'testReference',
references: ['id1'],
}),
});
});
});
describe('switching from reference to reference test cases', () => {
beforeEach(() => {
operationDefinitionMap.secondTest = {
input: 'fullReference',
displayName: 'Reference test 2',
// @ts-expect-error this type is not statically available
type: 'secondTest',
requiredReferences: [
{
// Any numeric metric that isn't also a reference
input: ['none', 'field'],
validateMetadata: (meta: OperationMetadata) =>
meta.dataType === 'number' && !meta.isBucketed,
},
],
// @ts-expect-error don't want to define valid arguments
buildColumn: jest.fn((args) => {
return {
label: 'Test reference',
isBucketed: false,
dataType: 'number',
operationType: 'secondTest',
references: args.referenceIds,
};
}),
isTransferable: jest.fn(),
toExpression: jest.fn().mockReturnValue([]),
getPossibleOperation: jest
.fn()
.mockReturnValue({ dataType: 'number', isBucketed: false }),
getDefaultLabel: jest.fn().mockReturnValue('Test reference'),
};
});
afterEach(() => {
delete operationDefinitionMap.secondTest;
});
it('should use existing references, delete invalid, when switching from one reference to another (case ref1)', () => {
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['ref1', 'invalid', 'output'],
columns: {
ref1: {
label: 'Count',
customLabel: true,
dataType: 'number' as const,
isBucketed: false,
operationType: 'count' as const,
sourceField: 'Records',
},
invalid: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: [],
},
output: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: ['ref1', 'invalid'],
},
},
};
expect(
replaceColumn({
layer,
indexPattern,
columnId: 'output',
// @ts-expect-error not statically available
op: 'secondTest',
})
).toEqual(
expect.objectContaining({
columnOrder: ['ref1', 'output'],
columns: {
ref1: layer.columns.ref1,
output: expect.objectContaining({ references: ['ref1'] }),
},
incompleteColumns: {},
})
);
});
it('should modify a copied object, not the original layer', () => {
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['ref1', 'invalid', 'output'],
columns: {
ref1: {
label: 'Count',
customLabel: true,
dataType: 'number' as const,
isBucketed: false,
operationType: 'count' as const,
sourceField: 'Records',
},
invalid: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: [],
},
output: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: ['ref1', 'invalid'],
},
},
};
replaceColumn({
layer,
indexPattern,
columnId: 'col2',
columnId: 'output',
// @ts-expect-error not statically available
op: 'secondTest',
})
).toEqual(
expect.objectContaining({
columnOrder: ['col2'],
columns: {
col2: expect.objectContaining({ references: ['id1'] }),
},
incompleteColumns: {},
})
);
});
expect(layer.columns.output).toEqual(
expect.objectContaining({ references: ['ref1', 'invalid'] })
);
});
delete operationDefinitionMap.secondTest;
it('should transition by using the field from the previous reference if nothing else works (case new5)', () => {
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['fieldReused', 'output'],
columns: {
fieldReused: {
label: 'Date histogram',
dataType: 'date' as const,
isBucketed: true,
operationType: 'date_histogram' as const,
sourceField: 'timestamp',
params: { interval: 'auto' },
},
output: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: ['fieldReused'],
},
},
};
expect(
replaceColumn({
layer,
indexPattern,
columnId: 'output',
// @ts-expect-error not statically available
op: 'secondTest',
})
).toEqual(
expect.objectContaining({
columnOrder: ['id1', 'output'],
columns: {
id1: expect.objectContaining({
sourceField: 'timestamp',
operationType: 'cardinality',
}),
output: expect.objectContaining({ references: ['id1'] }),
},
incompleteColumns: {},
})
);
});
});
describe('switching from reference to non-reference', () => {
it('should promote the inner references when switching away from reference to no-input (case a1)', () => {
// @ts-expect-error this function is not valid
operationDefinitionMap.testReference.requiredReferences = [
{
input: ['none'],
validateMetadata: () => true,
},
];
const expectedCol = {
label: 'Custom label',
customLabel: true,
dataType: 'string' as const,
isBucketed: true,
operationType: 'filters' as const,
params: {
// These filters are reset
filters: [
{ input: { query: 'field: true', language: 'kuery' }, label: 'Custom label' },
],
},
};
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: expectedCol,
col2: {
label: 'Test reference',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: ['col1'],
},
},
};
expect(
replaceColumn({
layer,
indexPattern,
columnId: 'col2',
op: 'filters',
})
).toEqual(
expect.objectContaining({
columnOrder: ['col2'],
columns: {
col2: expectedCol,
},
})
);
});
it('should promote the inner references when switching away from reference to field-based operation (case a2)', () => {
const expectedCol = {
label: 'Count of records',
dataType: 'number' as const,
isBucketed: false,
operationType: 'count' as const,
sourceField: 'Records',
};
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: expectedCol,
col2: {
label: 'Default label',
dataType: 'number',
isBucketed: false,
// @ts-expect-error not a valid type
operationType: 'testReference',
references: ['col1'],
},
},
};
expect(
replaceColumn({
layer,
indexPattern,
columnId: 'col2',
op: 'count',
field: documentField,
})
).toEqual(
expect.objectContaining({
columnOrder: ['col2'],
columns: {
col2: expect.objectContaining(expectedCol),
},
})
);
});
it('should promote only the field when going from reference to field-based operation (case a3)', () => {
const expectedColumn = {
dataType: 'number' as const,
isBucketed: false,
sourceField: 'bytes',
operationType: 'avg' as const,
};
const layer: IndexPatternLayer = {
indexPatternId: '1',
columnOrder: ['metric', 'ref'],
columns: {
metric: { ...expectedColumn, label: 'Avg', customLabel: true },
ref: {
label: 'Reference',
dataType: 'number',
isBucketed: false,
operationType: 'derivative',
references: ['metric'],
},
},
};
const result = replaceColumn({
layer,
indexPattern,
columnId: 'ref',
op: 'sum',
});
expect(result.columnOrder).toEqual(['ref']);
expect(result.columns).toEqual(
expect.objectContaining({
ref: expect.objectContaining({ ...expectedColumn, operationType: 'sum' }),
})
);
});
});
it('should allow making a replacement on an operation that is being referenced, even if it ends up invalid', () => {

View file

@ -5,6 +5,7 @@
*/
import _, { partition } from 'lodash';
import type { OperationMetadata } from '../../types';
import {
operationDefinitionMap,
operationDefinitions,
@ -59,17 +60,11 @@ export function insertNewColumn({
}
const possibleOperation = operationDefinition.getPossibleOperation();
const isBucketed = Boolean(possibleOperation.isBucketed);
if (isBucketed) {
return updateDefaultLabels(
addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId),
indexPattern
);
} else {
return updateDefaultLabels(
addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId),
indexPattern
);
}
const addOperationFn = isBucketed ? addBucket : addMetric;
return updateDefaultLabels(
addOperationFn(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId),
indexPattern
);
}
if (operationDefinition.input === 'fullReference') {
@ -78,9 +73,6 @@ export function insertNewColumn({
}
let tempLayer = { ...layer };
const referenceIds = operationDefinition.requiredReferences.map((validation) => {
// TODO: This logic is too simple because it's not using fields. Once we have
// access to the operationSupportMatrix, we should validate the metadata against
// the possible fields
const validOperations = Object.values(operationDefinitionMap).filter(({ type }) =>
isOperationAllowedAsReference({ validation, operationType: type, indexPattern })
);
@ -240,42 +232,77 @@ export function replaceColumn({
tempLayer = resetIncomplete(tempLayer, columnId);
if (operationDefinition.input === 'fullReference') {
return applyReferenceTransition({
layer: tempLayer,
columnId,
previousColumn,
op,
indexPattern,
});
}
// Makes common inferences about what the user meant when switching away from a reference:
// 1. Switching from "Differences of max" to "max" will promote as-is
// 2. Switching from "Differences of avg of bytes" to "max" will keep the field, but change operation
if (
previousDefinition.input === 'fullReference' &&
(previousColumn as ReferenceBasedIndexPatternColumn).references.length === 1
) {
const previousReferenceId = (previousColumn as ReferenceBasedIndexPatternColumn)
.references[0];
const referenceColumn = layer.columns[previousReferenceId];
if (referenceColumn) {
const referencedOperation = operationDefinitionMap[referenceColumn.operationType];
if (referencedOperation.type === op) {
// Unit tests are labelled as case a1, case a2
tempLayer = deleteColumn({
layer: tempLayer,
columnId: previousReferenceId,
indexPattern,
});
tempLayer = {
...tempLayer,
columns: {
...tempLayer.columns,
[columnId]: copyCustomLabel({ ...referenceColumn }, previousColumn),
},
};
return updateDefaultLabels(
{
...tempLayer,
columnOrder: getColumnOrder(tempLayer),
columns: adjustColumnReferencesForChangedColumn(tempLayer, columnId),
},
indexPattern
);
} else if (
!field &&
'sourceField' in referenceColumn &&
referencedOperation.input === 'field' &&
operationDefinition.input === 'field'
) {
// Unit test is case a3
const matchedField = indexPattern.getFieldByName(referenceColumn.sourceField);
if (matchedField && operationDefinition.getPossibleOperationForField(matchedField)) {
field = matchedField;
}
}
}
}
// This logic comes after the transitions because they need to look at previous columns
if (previousDefinition.input === 'fullReference') {
(previousColumn as ReferenceBasedIndexPatternColumn).references.forEach((id: string) => {
tempLayer = deleteColumn({ layer: tempLayer, columnId: id, indexPattern });
});
}
tempLayer = resetIncomplete(tempLayer, columnId);
if (operationDefinition.input === 'fullReference') {
const referenceIds = operationDefinition.requiredReferences.map(() => generateId());
const newLayer = {
...tempLayer,
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);
newColumn = copyCustomLabel(newColumn, previousColumn);
const newLayer = { ...tempLayer, columns: { ...tempLayer.columns, [columnId]: newColumn } };
return updateDefaultLabels(
@ -298,8 +325,18 @@ export function replaceColumn({
};
}
const validOperation = operationDefinition.getPossibleOperationForField(field);
if (!validOperation) {
return {
...tempLayer,
incompleteColumns: {
...(tempLayer.incompleteColumns ?? {}),
[columnId]: { operationType: op },
},
};
}
let newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, field });
newColumn = adjustLabel(newColumn, previousColumn);
newColumn = copyCustomLabel(newColumn, previousColumn);
const newLayer = { ...tempLayer, columns: { ...tempLayer.columns, [columnId]: newColumn } };
return updateDefaultLabels(
@ -317,34 +354,274 @@ export function replaceColumn({
previousColumn.sourceField !== field.name
) {
// Same operation, new field
const newColumn = operationDefinition.onFieldChange(previousColumn, field);
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
const newColumn = copyCustomLabel(
operationDefinition.onFieldChange(previousColumn, field),
previousColumn
);
const newLayer = resetIncomplete(
{ ...layer, columns: { ...layer.columns, [columnId]: newColumn } },
columnId
);
return {
...newLayer,
columnOrder: getColumnOrder(newLayer),
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
};
} else {
throw new Error('nothing changed');
}
}
function adjustLabel(newColumn: IndexPatternColumn, previousColumn: IndexPatternColumn) {
export function canTransition({
layer,
columnId,
op,
field,
indexPattern,
filterOperations,
}: ColumnChange & {
filterOperations: (meta: OperationMetadata) => boolean;
}): boolean {
const previousColumn = layer.columns[columnId];
if (!previousColumn) {
return true;
}
if (previousColumn.operationType === op) {
return true;
}
try {
const newLayer = replaceColumn({ layer, columnId, op, field, indexPattern });
const newDefinition = operationDefinitionMap[op];
const newColumn = newLayer.columns[columnId];
return (
Boolean(newColumn) &&
!newLayer.incompleteColumns?.[columnId] &&
filterOperations(newColumn) &&
!newDefinition.getErrorMessage?.(newLayer, columnId, indexPattern)
);
} catch (e) {
return false;
}
}
/**
* Function to transition to a fullReference from any different operation.
* It is always possible to transition to a fullReference, but there are multiple
* passes needed to copy all the previous state. These are the passes in priority
* order, each of which has a unit test:
*
* 1. Case ref1: referenced columns are an exact match
* Side effect: Modifies the reference list directly
* 2. Case new1: the previous column is an exact match.
* Side effect: Deletes and then inserts the previous column
* 3. Case new2: the reference supports `none` inputs, like filters. not visible in the UI.
* Side effect: Inserts a new column
* 4. Case new3, new4: Fuzzy matching on the previous field
* Side effect: Inserts a new column, or an incomplete column
* 5. Fuzzy matching based on the previous references (case new6)
* Side effect: Inserts a new column, or an incomplete column
* Side effect: Modifies the reference list directly
* 6. Case new6: Fall back by generating the column with empty references
*/
function applyReferenceTransition({
layer,
columnId,
previousColumn,
op,
indexPattern,
}: {
layer: IndexPatternLayer;
columnId: string;
previousColumn: IndexPatternColumn;
op: OperationType;
indexPattern: IndexPattern;
}): IndexPatternLayer {
const operationDefinition = operationDefinitionMap[op];
if (operationDefinition.input !== 'fullReference') {
throw new Error(`Requirements for transitioning are not met`);
}
let hasExactMatch = false;
let hasFieldMatch = false;
const unusedReferencesQueue =
'references' in previousColumn
? [...(previousColumn as ReferenceBasedIndexPatternColumn).references]
: [];
const referenceIds = operationDefinition.requiredReferences.map((validation) => {
const newId = generateId();
// First priority is to use any references that can be kept (case ref1)
if (unusedReferencesQueue.length) {
const otherColumn = layer.columns[unusedReferencesQueue[0]];
if (isColumnValidAsReference({ validation, column: otherColumn })) {
return unusedReferencesQueue.shift()!;
}
}
// Second priority is to wrap around the previous column (case new1)
if (!hasExactMatch && isColumnValidAsReference({ validation, column: previousColumn })) {
hasExactMatch = true;
const newLayer = { ...layer, columns: { ...layer.columns, [newId]: { ...previousColumn } } };
layer = {
...layer,
columnOrder: getColumnOrder(newLayer),
columns: adjustColumnReferencesForChangedColumn(newLayer, newId),
};
return newId;
}
// Look for any fieldless operations that can be inserted directly (case new2)
if (validation.input.includes('none')) {
const validOperations = operationDefinitions.filter((def) => {
if (def.input !== 'none') return;
return isOperationAllowedAsReference({
validation,
operationType: def.type,
indexPattern,
});
});
if (validOperations.length === 1) {
layer = insertNewColumn({
layer,
columnId: newId,
op: validOperations[0].type,
indexPattern,
});
return newId;
}
}
// Try to reuse the previous field by finding a possible operation. Because we've alredy
// checked for an exact operation match, this is guaranteed to be different from previousColumn
if (!hasFieldMatch && 'sourceField' in previousColumn && validation.input.includes('field')) {
const defIgnoringfield = operationDefinitions
.filter(
(def) =>
def.input === 'field' &&
isOperationAllowedAsReference({ validation, operationType: def.type, indexPattern })
)
.sort(getSortScoreByPriority);
// No exact match found, so let's determine that the current field can be reused
const defWithField = defIgnoringfield.filter((def) => {
const previousField = indexPattern.getFieldByName(previousColumn.sourceField);
if (!previousField) return;
return isOperationAllowedAsReference({
validation,
operationType: def.type,
field: previousField,
indexPattern,
});
});
if (defWithField.length > 0) {
// Found the best match that keeps the field (case new3)
hasFieldMatch = true;
layer = insertNewColumn({
layer,
columnId: newId,
op: defWithField[0].type,
indexPattern,
field: indexPattern.getFieldByName(previousColumn.sourceField),
});
return newId;
} else if (defIgnoringfield.length === 1) {
// Can't use the field, but there is an exact match on the operation (case new4)
hasFieldMatch = true;
layer = {
...layer,
incompleteColumns: {
...layer.incompleteColumns,
[newId]: { operationType: defIgnoringfield[0].type },
},
};
return newId;
}
}
// Look for field-based references that we can use to assign a new field-based operation from (case new5)
if (unusedReferencesQueue.length) {
const otherColumn = layer.columns[unusedReferencesQueue[0]];
if (otherColumn && 'sourceField' in otherColumn && validation.input.includes('field')) {
const previousField = indexPattern.getFieldByName(otherColumn.sourceField);
if (previousField) {
const defWithField = operationDefinitions
.filter(
(def) =>
def.input === 'field' &&
isOperationAllowedAsReference({
validation,
operationType: def.type,
field: previousField,
indexPattern,
})
)
.sort(getSortScoreByPriority);
if (defWithField.length > 0) {
layer = insertNewColumn({
layer,
columnId: newId,
op: defWithField[0].type,
indexPattern,
field: previousField,
});
return newId;
}
}
}
}
// The reference is too ambiguous at this point, but instead of throwing an error (case new6)
return newId;
});
if (unusedReferencesQueue.length) {
unusedReferencesQueue.forEach((id: string) => {
layer = deleteColumn({
layer,
columnId: id,
indexPattern,
});
});
}
layer = {
...layer,
columns: {
...layer.columns,
[columnId]: operationDefinition.buildColumn({
indexPattern,
layer,
referenceIds,
previousColumn,
}),
},
};
return updateDefaultLabels(
{
...layer,
columnOrder: getColumnOrder(layer),
columns: adjustColumnReferencesForChangedColumn(layer, columnId),
},
indexPattern
);
}
function copyCustomLabel(newColumn: IndexPatternColumn, previousColumn: IndexPatternColumn) {
const adjustedColumn = { ...newColumn };
if (previousColumn.customLabel) {
adjustedColumn.customLabel = true;
adjustedColumn.label = previousColumn.label;
}
return adjustedColumn;
}
@ -664,3 +941,20 @@ export function resetIncomplete(layer: IndexPatternLayer, columnId: string): Ind
delete incompleteColumns[columnId];
return { ...layer, incompleteColumns };
}
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)
);
}

View file

@ -74,7 +74,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_splitDimensionPanel > lns-dimensionTrigger',
operation: 'filters',
isPreviousIncompatible: true,
keepOpen: true,
});
await PageObjects.lens.addFilterToAgg(`geo.src : CN`);
@ -478,6 +477,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
});
it('should keep the field selection while transitioning to every reference-based operation', 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: 'avg',
field: 'bytes',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-dimensionTrigger',
operation: 'counter_rate',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-dimensionTrigger',
operation: 'cumulative_sum',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-dimensionTrigger',
operation: 'derivative',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-dimensionTrigger',
operation: 'moving_average',
});
expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql(
'Moving average of Sum of bytes'
);
});
it('should allow to change index pattern', async () => {
await PageObjects.lens.switchFirstLayerIndexPattern('log*');
expect(await PageObjects.lens.getFirstLayerIndexPattern()).to.equal('log*');