* [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:
parent
646bdb1342
commit
1bb1c030f6
7 changed files with 1035 additions and 292 deletions
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -43,6 +43,7 @@ export const {
|
|||
isReferenced,
|
||||
resetIncomplete,
|
||||
isOperationAllowedAsReference,
|
||||
canTransition,
|
||||
} = actualHelpers;
|
||||
|
||||
export const { adjustTimeScaleLabelSuffix, DEFAULT_TIME_SCALE } = actualTimeScaleUtils;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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*');
|
||||
|
|
Loading…
Reference in a new issue