[Lens] Fieldless operations (#78080)

* [Lens] Fieldless operations

* Overhaul types

* Fix invalid state and add tests

* Fix types

* Small cleanup

* Add additional error message

* Reset field selector to empty state when invalid

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Wylie Conlon 2020-09-28 11:22:03 -04:00 committed by GitHub
parent db78d70df3
commit 0ebaf92a6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 568 additions and 557 deletions

View file

@ -17,7 +17,7 @@ import {
} from '@elastic/eui';
import { EuiFormLabel } from '@elastic/eui';
import { IndexPatternColumn, OperationType } from '../indexpattern';
import { IndexPatternDimensionEditorProps, OperationFieldSupportMatrix } from './dimension_panel';
import { IndexPatternDimensionEditorProps, OperationSupportMatrix } from './dimension_panel';
import {
operationDefinitionMap,
getOperationDisplay,
@ -36,7 +36,7 @@ const operationPanels = getOperationDisplay();
export interface DimensionEditorProps extends IndexPatternDimensionEditorProps {
selectedColumn?: IndexPatternColumn;
operationFieldSupportMatrix: OperationFieldSupportMatrix;
operationSupportMatrix: OperationSupportMatrix;
currentIndexPattern: IndexPattern;
}
@ -90,7 +90,7 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri
export function DimensionEditor(props: DimensionEditorProps) {
const {
selectedColumn,
operationFieldSupportMatrix,
operationSupportMatrix,
state,
columnId,
setState,
@ -98,14 +98,16 @@ export function DimensionEditor(props: DimensionEditorProps) {
currentIndexPattern,
hideGrouping,
} = props;
const { operationByField, fieldByOperation } = operationFieldSupportMatrix;
const { operationByField, fieldByOperation } = operationSupportMatrix;
const [
incompatibleSelectedOperationType,
setInvalidOperationType,
] = useState<OperationType | null>(null);
const ParamEditor =
selectedColumn && operationDefinitionMap[selectedColumn.operationType].paramEditor;
const selectedOperationDefinition =
selectedColumn && operationDefinitionMap[selectedColumn.operationType];
const ParamEditor = selectedOperationDefinition?.paramEditor;
const fieldMap: Record<string, IndexPatternField> = useMemo(() => {
const fields: Record<string, IndexPatternField> = {};
@ -129,6 +131,10 @@ export function DimensionEditor(props: DimensionEditorProps) {
[
...asOperationOptions(validOperationTypes, true),
...asOperationOptions(possibleOperationTypes, false),
...asOperationOptions(
operationSupportMatrix.operationWithoutField,
!selectedColumn || !hasField(selectedColumn)
),
],
'operationType'
);
@ -166,12 +172,30 @@ export function DimensionEditor(props: DimensionEditorProps) {
compatibleWithCurrentField ? '' : ' incompatible'
}`,
onClick() {
// todo: when moving from terms agg to filters, we want to create a filter `$field.name : *`
// it probably has to be re-thought when removing the field name.
const isTermsToFilters =
selectedColumn?.operationType === 'terms' && operationType === 'filters';
if (!selectedColumn || !compatibleWithCurrentField) {
if (operationDefinitionMap[operationType].input === 'none') {
// Clear invalid state because we are creating a valid column
setInvalidOperationType(null);
if (selectedColumn?.operationType === operationType) {
return;
}
setState(
changeColumn({
state,
layerId,
columnId,
newColumn: buildColumn({
columns: props.state.layers[props.layerId].columns,
suggestedPriority: props.suggestedPriority,
layerId: props.layerId,
op: operationType,
indexPattern: currentIndexPattern,
previousColumn: selectedColumn,
}),
})
);
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
return;
} else if (!selectedColumn || !compatibleWithCurrentField) {
const possibleFields = fieldByOperation[operationType] || [];
if (possibleFields.length === 1) {
@ -197,19 +221,20 @@ export function DimensionEditor(props: DimensionEditorProps) {
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
return;
}
if (incompatibleSelectedOperationType && !isTermsToFilters) {
setInvalidOperationType(null);
}
if (selectedColumn.operationType === operationType) {
setInvalidOperationType(null);
if (selectedColumn?.operationType === operationType) {
return;
}
const newColumn: IndexPatternColumn = buildColumn({
columns: props.state.layers[props.layerId].columns,
suggestedPriority: props.suggestedPriority,
layerId: props.layerId,
op: operationType,
indexPattern: currentIndexPattern,
field: fieldMap[selectedColumn.sourceField],
field: hasField(selectedColumn) ? fieldMap[selectedColumn.sourceField] : undefined,
previousColumn: selectedColumn,
});
@ -244,93 +269,101 @@ export function DimensionEditor(props: DimensionEditorProps) {
</div>
<EuiSpacer size="s" />
<div className="lnsIndexPatternDimensionEditor__section lnsIndexPatternDimensionEditor__section--shaded">
<EuiFormRow
data-test-subj="indexPattern-field-selection-row"
label={i18n.translate('xpack.lens.indexPattern.chooseField', {
defaultMessage: 'Choose a field',
})}
fullWidth
isInvalid={Boolean(incompatibleSelectedOperationType)}
error={
selectedColumn
? i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
defaultMessage: 'To use this function, select a different field.',
})
: undefined
}
>
<FieldSelect
currentIndexPattern={currentIndexPattern}
existingFields={state.existingFields}
fieldMap={fieldMap}
operationFieldSupportMatrix={operationFieldSupportMatrix}
selectedColumnOperationType={selectedColumn && selectedColumn.operationType}
selectedColumnSourceField={
selectedColumn && hasField(selectedColumn) ? selectedColumn.sourceField : undefined
{!selectedColumn ||
selectedOperationDefinition?.input === 'field' ||
(incompatibleSelectedOperationType &&
operationDefinitionMap[incompatibleSelectedOperationType].input === 'field') ? (
<EuiFormRow
data-test-subj="indexPattern-field-selection-row"
label={i18n.translate('xpack.lens.indexPattern.chooseField', {
defaultMessage: 'Choose a field',
})}
fullWidth
isInvalid={Boolean(incompatibleSelectedOperationType)}
error={
selectedColumn && incompatibleSelectedOperationType
? selectedOperationDefinition?.input === 'field'
? i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
defaultMessage: 'To use this function, select a different field.',
})
: i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', {
defaultMessage: 'To use this function, select a field.',
})
: undefined
}
incompatibleSelectedOperationType={incompatibleSelectedOperationType}
onDeleteColumn={() => {
setState(
deleteColumn({
state,
layerId,
columnId,
})
);
}}
onChoose={(choice) => {
let column: IndexPatternColumn;
if (
!incompatibleSelectedOperationType &&
selectedColumn &&
'field' in choice &&
choice.operationType === selectedColumn.operationType
) {
// If we just changed the field are not in an error state and the operation didn't change,
// we use the operations onFieldChange method to calculate the new column.
column = changeField(selectedColumn, currentIndexPattern, fieldMap[choice.field]);
} else {
// Otherwise we'll use the buildColumn method to calculate a new column
const compatibleOperations =
('field' in choice &&
operationFieldSupportMatrix.operationByField[choice.field]) ||
[];
let operation;
if (compatibleOperations.length > 0) {
operation =
incompatibleSelectedOperationType &&
compatibleOperations.includes(incompatibleSelectedOperationType)
? incompatibleSelectedOperationType
: compatibleOperations[0];
} else if ('field' in choice) {
operation = choice.operationType;
}
column = buildColumn({
columns: props.state.layers[props.layerId].columns,
field: fieldMap[choice.field],
indexPattern: currentIndexPattern,
layerId: props.layerId,
suggestedPriority: props.suggestedPriority,
op: operation as OperationType,
previousColumn: selectedColumn,
});
>
<FieldSelect
currentIndexPattern={currentIndexPattern}
existingFields={state.existingFields}
fieldMap={fieldMap}
operationSupportMatrix={operationSupportMatrix}
selectedColumnOperationType={selectedColumn && selectedColumn.operationType}
selectedColumnSourceField={
selectedColumn && hasField(selectedColumn) ? selectedColumn.sourceField : undefined
}
incompatibleSelectedOperationType={incompatibleSelectedOperationType}
onDeleteColumn={() => {
setState(
deleteColumn({
state,
layerId,
columnId,
})
);
}}
onChoose={(choice) => {
let column: IndexPatternColumn;
if (
!incompatibleSelectedOperationType &&
selectedColumn &&
'field' in choice &&
choice.operationType === selectedColumn.operationType
) {
// If we just changed the field are not in an error state and the operation didn't change,
// we use the operations onFieldChange method to calculate the new column.
column = changeField(selectedColumn, currentIndexPattern, fieldMap[choice.field]);
} else {
// Otherwise we'll use the buildColumn method to calculate a new column
const compatibleOperations =
('field' in choice && operationSupportMatrix.operationByField[choice.field]) ||
[];
let operation;
if (compatibleOperations.length > 0) {
operation =
incompatibleSelectedOperationType &&
compatibleOperations.includes(incompatibleSelectedOperationType)
? incompatibleSelectedOperationType
: compatibleOperations[0];
} else if ('field' in choice) {
operation = choice.operationType;
}
column = buildColumn({
columns: props.state.layers[props.layerId].columns,
field: fieldMap[choice.field],
indexPattern: currentIndexPattern,
layerId: props.layerId,
suggestedPriority: props.suggestedPriority,
op: operation as OperationType,
previousColumn: selectedColumn,
});
}
setState(
changeColumn({
state,
layerId,
columnId,
newColumn: column,
keepParams: false,
})
);
setInvalidOperationType(null);
}}
/>
</EuiFormRow>
setState(
changeColumn({
state,
layerId,
columnId,
newColumn: column,
keepParams: false,
})
);
setInvalidOperationType(null);
}}
/>
</EuiFormRow>
) : null}
{!incompatibleSelectedOperationType && ParamEditor && (
{!incompatibleSelectedOperationType && selectedColumn && ParamEditor && (
<>
<ParamEditor
state={state}

View file

@ -22,6 +22,7 @@ import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/e
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { IndexPatternPrivateState } from '../types';
import { IndexPatternColumn } from '../operations';
import { documentField } from '../document_field';
import { OperationMetadata } from '../../types';
@ -81,12 +82,39 @@ const expectedIndexPatterns = {
},
};
const bytesColumn: IndexPatternColumn = {
label: 'Max of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'max',
sourceField: 'bytes',
params: { format: { id: 'bytes' } },
};
/**
* The datasource exposes four main pieces of code which are tested at
* an integration test level. The main reason for this fairly high level
* of testing is that there is a lot of UI logic that isn't easily
* unit tested, such as the transient invalid state.
*
* - Dimension trigger: Not tested here
* - Dimension editor component: First half of the tests
*
* - canHandleDrop: Tests for dropping of fields or other dimensions
* - onDrop: Correct application of drop logic
*/
describe('IndexPatternDimensionEditorPanel', () => {
let state: IndexPatternPrivateState;
let setState: jest.Mock;
let defaultProps: IndexPatternDimensionEditorProps;
let dragDropContext: DragContextState;
function getStateWithColumns(columns: Record<string, IndexPatternColumn>) {
return { ...state, layers: { first: { ...state.layers.first, columns } } };
}
beforeEach(() => {
state = {
indexPatternRefs: [],
@ -179,7 +207,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
expect(filterOperations).toBeCalled();
});
it('should show field select combo box on click', () => {
it('should show field select', () => {
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} />);
expect(
@ -187,6 +215,29 @@ describe('IndexPatternDimensionEditorPanel', () => {
).toHaveLength(1);
});
it('should not show field select on fieldless operation', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={getStateWithColumns({
col1: {
label: 'Filters',
dataType: 'string',
isBucketed: false,
// Private
operationType: 'filters',
params: { filters: [] },
},
})}
/>
);
expect(
wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]')
).toHaveLength(0);
});
it('should not show any choices if the filter returns false', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
@ -250,26 +301,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={{
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
label: 'Max of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'max',
sourceField: 'bytes',
},
},
},
},
}}
state={getStateWithColumns({ col1: bytesColumn })}
/>
);
@ -292,26 +324,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={{
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
label: 'Max of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'max',
sourceField: 'bytes',
},
},
},
},
}}
state={getStateWithColumns({ col1: bytesColumn })}
/>
);
@ -324,30 +337,15 @@ describe('IndexPatternDimensionEditorPanel', () => {
expect(items.find(({ label }) => label === 'Date histogram')!['data-test-subj']).toContain(
'incompatible'
);
// Fieldless operation is compatible with field
expect(items.find(({ label }) => label === 'Filters')!['data-test-subj']).toContain(
'compatible'
);
});
it('should keep the operation when switching to another field compatible with this operation', () => {
const initialState: IndexPatternPrivateState = {
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
label: 'Max of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'max',
sourceField: 'bytes',
params: { format: { id: 'bytes' } },
},
},
},
},
};
const initialState: IndexPatternPrivateState = getStateWithColumns({ col1: bytesColumn });
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={initialState} />
@ -415,27 +413,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={{
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
label: 'Max of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'max',
sourceField: 'bytes',
params: { format: { id: 'bytes' } },
},
},
},
},
}}
state={getStateWithColumns({ col1: bytesColumn })}
/>
);
@ -505,27 +483,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={{
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
label: 'Max of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'max',
sourceField: 'bytes',
params: { format: { id: 'bytes' } },
},
},
},
},
}}
state={getStateWithColumns({ col1: bytesColumn })}
/>
);
@ -553,28 +511,13 @@ describe('IndexPatternDimensionEditorPanel', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={{
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
label: 'Custom label',
customLabel: true,
dataType: 'number',
isBucketed: false,
// Private
operationType: 'max',
sourceField: 'bytes',
params: { format: { id: 'bytes' } },
},
},
},
state={getStateWithColumns({
col1: {
...bytesColumn,
label: 'Custom label',
customLabel: true,
},
}}
})}
/>
);
@ -640,6 +583,62 @@ describe('IndexPatternDimensionEditorPanel', () => {
expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0);
});
it('should leave error state if the original operation is re-selected', () => {
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} />);
wrapper
.find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]')
.simulate('click');
wrapper
.find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]')
.simulate('click');
expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0);
});
it('should leave error state when switching from incomplete state to fieldless operation', () => {
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} />);
wrapper
.find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]')
.simulate('click');
wrapper
.find('button[data-test-subj="lns-indexPatternDimension-filters incompatible"]')
.simulate('click');
expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0);
});
it('should leave error state when re-selecting the original fieldless function', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={getStateWithColumns({
col1: {
label: 'Filter',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'filters',
params: { filters: [] },
},
})}
/>
);
wrapper
.find('button[data-test-subj="lns-indexPatternDimension-terms 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);
});
it('should indicate fields compatible with selected operation', () => {
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} />);
@ -701,28 +700,18 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
it('should select the Records field when count is selected', () => {
const initialState: IndexPatternPrivateState = {
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col2: {
dataType: 'number',
isBucketed: false,
label: '',
operationType: 'avg',
sourceField: 'bytes',
},
},
},
},
};
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={initialState}
state={getStateWithColumns({
col2: {
dataType: 'number',
isBucketed: false,
label: '',
operationType: 'avg',
sourceField: 'bytes',
},
})}
columnId="col2"
/>
);
@ -737,28 +726,18 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
it('should indicate document and field compatibility with selected document operation', () => {
const initialState: IndexPatternPrivateState = {
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col2: {
dataType: 'number',
isBucketed: false,
label: '',
operationType: 'count',
sourceField: 'Records',
},
},
},
},
};
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={initialState}
state={getStateWithColumns({
col2: {
dataType: 'number',
isBucketed: false,
label: '',
operationType: 'count',
sourceField: 'Records',
},
})}
columnId="col2"
/>
);
@ -942,28 +921,18 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
it('should indicate document compatibility when document operation is selected', () => {
const initialState: IndexPatternPrivateState = {
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col2: {
dataType: 'number',
isBucketed: false,
label: '',
operationType: 'count',
sourceField: 'Records',
},
},
},
},
};
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={initialState}
state={getStateWithColumns({
col2: {
dataType: 'number',
isBucketed: false,
label: '',
operationType: 'count',
sourceField: 'Records',
},
})}
columnId={'col2'}
/>
);
@ -1031,26 +1000,9 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
it('should use helper function when changing the function', () => {
const initialState: IndexPatternPrivateState = {
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
label: 'Max of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'max',
sourceField: 'bytes',
},
},
},
},
};
const initialState: IndexPatternPrivateState = getStateWithColumns({
col1: bytesColumn,
});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={initialState} />
);
@ -1095,25 +1047,16 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
it('allows custom format', () => {
const stateWithNumberCol: IndexPatternPrivateState = {
...state,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Average of memory',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'avg',
sourceField: 'memory',
},
},
},
const stateWithNumberCol: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Average of memory',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'avg',
sourceField: 'memory',
},
};
});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithNumberCol} />
@ -1145,29 +1088,19 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
it('keeps decimal places while switching', () => {
const stateWithNumberCol: IndexPatternPrivateState = {
...state,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Average of memory',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'avg',
sourceField: 'memory',
params: {
format: { id: 'bytes', params: { decimals: 0 } },
},
},
},
const stateWithNumberCol: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Average of memory',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'avg',
sourceField: 'memory',
params: {
format: { id: 'bytes', params: { decimals: 0 } },
},
},
};
});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithNumberCol} />
);
@ -1195,28 +1128,19 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
it('allows custom format with number of decimal places', () => {
const stateWithNumberCol: IndexPatternPrivateState = {
...state,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Average of memory',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'avg',
sourceField: 'memory',
params: {
format: { id: 'bytes', params: { decimals: 2 } },
},
},
},
const stateWithNumberCol: IndexPatternPrivateState = getStateWithColumns({
col1: {
label: 'Average of memory',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'avg',
sourceField: 'memory',
params: {
format: { id: 'bytes', params: { decimals: 2 } },
},
},
};
});
wrapper = mount(
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithNumberCol} />

View file

@ -46,8 +46,9 @@ export type IndexPatternDimensionEditorProps = DatasourceDimensionEditorProps<
dateRange: DateRange;
};
export interface OperationFieldSupportMatrix {
export interface OperationSupportMatrix {
operationByField: Partial<Record<string, OperationType[]>>;
operationWithoutField: OperationType[];
fieldByOperation: Partial<Record<OperationType, string[]>>;
}
@ -58,7 +59,7 @@ type Props = Pick<
// TODO: This code has historically been memoized, as a potentially performance
// sensitive task. If we can add memoization without breaking the behavior, we should.
const getOperationFieldSupportMatrix = (props: Props): OperationFieldSupportMatrix => {
const getOperationSupportMatrix = (props: Props): OperationSupportMatrix => {
const layerId = props.layerId;
const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId];
@ -67,37 +68,43 @@ const getOperationFieldSupportMatrix = (props: Props): OperationFieldSupportMatr
).filter((operation) => props.filterOperations(operation.operationMetaData));
const supportedOperationsByField: Partial<Record<string, OperationType[]>> = {};
const supportedOperationsWithoutField: OperationType[] = [];
const supportedFieldsByOperation: Partial<Record<OperationType, string[]>> = {};
filteredOperationsByMetadata.forEach(({ operations }) => {
operations.forEach((operation) => {
if (supportedOperationsByField[operation.field]) {
supportedOperationsByField[operation.field]!.push(operation.operationType);
} else {
supportedOperationsByField[operation.field] = [operation.operationType];
}
if (operation.type === 'field') {
if (supportedOperationsByField[operation.field]) {
supportedOperationsByField[operation.field]!.push(operation.operationType);
} else {
supportedOperationsByField[operation.field] = [operation.operationType];
}
if (supportedFieldsByOperation[operation.operationType]) {
supportedFieldsByOperation[operation.operationType]!.push(operation.field);
} else {
supportedFieldsByOperation[operation.operationType] = [operation.field];
if (supportedFieldsByOperation[operation.operationType]) {
supportedFieldsByOperation[operation.operationType]!.push(operation.field);
} else {
supportedFieldsByOperation[operation.operationType] = [operation.field];
}
} else if (operation.type === 'none') {
supportedOperationsWithoutField.push(operation.operationType);
}
});
});
return {
operationByField: _.mapValues(supportedOperationsByField, _.uniq),
operationWithoutField: _.uniq(supportedOperationsWithoutField),
fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq),
};
};
export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPrivateState>) {
const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props);
const operationSupportMatrix = getOperationSupportMatrix(props);
const { dragging } = props.dragDropContext;
const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId;
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationFieldSupportMatrix.operationByField[field.name]);
return Boolean(operationSupportMatrix.operationByField[field.name]);
}
if (isDraggedField(dragging)) {
@ -119,11 +126,11 @@ export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPr
}
export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) {
const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props);
const operationSupportMatrix = getOperationSupportMatrix(props);
const droppedItem = props.droppedItem;
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationFieldSupportMatrix.operationByField[field.name]);
return Boolean(operationSupportMatrix.operationByField[field.name]);
}
if (isDraggedOperation(droppedItem) && droppedItem.layerId === props.layerId) {
@ -167,8 +174,7 @@ export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPr
return false;
}
const operationsForNewField =
operationFieldSupportMatrix.operationByField[droppedItem.field.name];
const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name];
const layerId = props.layerId;
const selectedColumn: IndexPatternColumn | null =
@ -259,7 +265,7 @@ export const IndexPatternDimensionEditorComponent = function IndexPatternDimensi
const layerId = props.layerId;
const currentIndexPattern =
props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId];
const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props);
const operationSupportMatrix = getOperationSupportMatrix(props);
const selectedColumn: IndexPatternColumn | null =
props.state.layers[layerId].columns[props.columnId] || null;
@ -269,7 +275,7 @@ export const IndexPatternDimensionEditorComponent = function IndexPatternDimensi
{...props}
currentIndexPattern={currentIndexPattern}
selectedColumn={selectedColumn}
operationFieldSupportMatrix={operationFieldSupportMatrix}
operationSupportMatrix={operationSupportMatrix}
/>
);
};

View file

@ -20,7 +20,7 @@ import { EuiHighlight } from '@elastic/eui';
import { OperationType } from '../indexpattern';
import { LensFieldIcon } from '../lens_field_icon';
import { DataType } from '../../types';
import { OperationFieldSupportMatrix } from './dimension_panel';
import { OperationSupportMatrix } from './dimension_panel';
import { IndexPattern, IndexPatternField, IndexPatternPrivateState } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { fieldExists } from '../pure_helpers';
@ -37,7 +37,7 @@ export interface FieldSelectProps extends EuiComboBoxProps<{}> {
incompatibleSelectedOperationType: OperationType | null;
selectedColumnOperationType?: OperationType;
selectedColumnSourceField?: string;
operationFieldSupportMatrix: OperationFieldSupportMatrix;
operationSupportMatrix: OperationSupportMatrix;
onChoose: (choice: FieldChoice) => void;
onDeleteColumn: () => void;
existingFields: IndexPatternPrivateState['existingFields'];
@ -49,13 +49,13 @@ export function FieldSelect({
incompatibleSelectedOperationType,
selectedColumnOperationType,
selectedColumnSourceField,
operationFieldSupportMatrix,
operationSupportMatrix,
onChoose,
onDeleteColumn,
existingFields,
...rest
}: FieldSelectProps) {
const { operationByField } = operationFieldSupportMatrix;
const { operationByField } = operationSupportMatrix;
const memoizedFieldOptions = useMemo(() => {
const fields = Object.keys(operationByField).sort();
@ -173,15 +173,13 @@ export function FieldSelect({
options={(memoizedFieldOptions as unknown) as EuiComboBoxOptionOption[]}
isInvalid={Boolean(incompatibleSelectedOperationType)}
selectedOptions={
((selectedColumnOperationType
? selectedColumnSourceField
? [
{
label: fieldMap[selectedColumnSourceField].displayName,
value: { type: 'field', field: selectedColumnSourceField },
},
]
: [memoizedFieldOptions[0]]
((selectedColumnOperationType && selectedColumnSourceField
? [
{
label: fieldMap[selectedColumnSourceField].displayName,
value: { type: 'field', field: selectedColumnSourceField },
},
]
: []) as unknown) as EuiComboBoxOptionOption[]
}
singleSelection={{ asPlainText: true }}

View file

@ -483,11 +483,15 @@ function createChangedNestingSuggestion(state: IndexPatternPrivateState, layerId
const updatedLayer = { ...layer, columnOrder: [secondBucket, firstBucket, ...rest] };
const currentFields = state.indexPatterns[state.currentIndexPatternId].fields;
const firstBucketLabel =
currentFields.find((field) => field.name === layer.columns[firstBucket].sourceField)
?.displayName || '';
currentFields.find((field) => {
const column = layer.columns[firstBucket];
return hasField(column) && column.sourceField === field.name;
})?.displayName || '';
const secondBucketLabel =
currentFields.find((field) => field.name === layer.columns[secondBucket].sourceField)
?.displayName || '';
currentFields.find((field) => {
const column = layer.columns[secondBucket];
return hasField(column) && column.sourceField === field.name;
})?.displayName || '';
return buildSuggestion({
state,

View file

@ -6,7 +6,7 @@
import { i18n } from '@kbn/i18n';
import { OperationDefinition } from './index';
import { FormattedIndexPatternColumn } from './column_types';
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
@ -21,15 +21,18 @@ function ofName(name: string) {
});
}
export interface CardinalityIndexPatternColumn extends FormattedIndexPatternColumn {
export interface CardinalityIndexPatternColumn
extends FormattedIndexPatternColumn,
FieldBasedIndexPatternColumn {
operationType: 'cardinality';
}
export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternColumn> = {
export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternColumn, 'field'> = {
type: OPERATION_TYPE,
displayName: i18n.translate('xpack.lens.indexPattern.cardinality', {
defaultMessage: 'Unique count',
}),
input: 'field',
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
supportedTypes.has(type) &&

View file

@ -14,7 +14,6 @@ import { Operation, DimensionPriority } from '../../../types';
export interface BaseIndexPatternColumn extends Operation {
// Private
operationType: string;
sourceField: string;
suggestedPriority?: DimensionPriority;
customLabel?: boolean;
}
@ -31,23 +30,6 @@ export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn {
};
}
/**
* Base type for a column that doesn't have additional parameter.
*
* * `TOperationType` should be a string type containing just the type
* of the operation (e.g. `"sum"`).
* * `TBase` is the base column interface the operation type is set for -
* by default this is `FieldBasedIndexPatternColumn`, so
* `ParameterlessIndexPatternColumn<'foo'>` will give you a column type
* for an operation named foo that operates on a field.
* By passing in another `TBase` (e.g. just `BaseIndexPatternColumn`),
* you can also create other column types.
*/
export type ParameterlessIndexPatternColumn<
TOperationType extends string,
TBase extends BaseIndexPatternColumn = FieldBasedIndexPatternColumn
> = TBase & { operationType: TOperationType };
export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn {
suggestedPriority?: DimensionPriority;
sourceField: string;
}

View file

@ -6,23 +6,25 @@
import { i18n } from '@kbn/i18n';
import { OperationDefinition } from './index';
import { FormattedIndexPatternColumn } from './column_types';
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
import { IndexPatternField } from '../../types';
const countLabel = i18n.translate('xpack.lens.indexPattern.countOf', {
defaultMessage: 'Count of records',
});
export type CountIndexPatternColumn = FormattedIndexPatternColumn & {
operationType: 'count';
};
export type CountIndexPatternColumn = FormattedIndexPatternColumn &
FieldBasedIndexPatternColumn & {
operationType: 'count';
};
export const countOperation: OperationDefinition<CountIndexPatternColumn> = {
export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field'> = {
type: 'count',
priority: 2,
displayName: i18n.translate('xpack.lens.indexPattern.count', {
defaultMessage: 'Count',
}),
input: 'field',
onFieldChange: (oldColumn, indexPattern, field) => {
return {
...oldColumn,

View file

@ -36,11 +36,15 @@ export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternC
};
}
export const dateHistogramOperation: OperationDefinition<DateHistogramIndexPatternColumn> = {
export const dateHistogramOperation: OperationDefinition<
DateHistogramIndexPatternColumn,
'field'
> = {
type: 'date_histogram',
displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', {
defaultMessage: 'Date histogram',
}),
input: 'field',
priority: 5, // Highest priority level used
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
@ -136,7 +140,7 @@ export const dateHistogramOperation: OperationDefinition<DateHistogramIndexPatte
},
};
},
paramEditor: ({ state, setState, currentColumn: currentColumn, layerId, dateRange, data }) => {
paramEditor: ({ state, setState, currentColumn, layerId, dateRange, data }) => {
const field =
currentColumn &&
state.indexPatterns[state.layers[layerId].indexPatternId].fields.find(

View file

@ -59,7 +59,6 @@ describe('filters', () => {
operationType: 'filters',
scale: 'ordinal',
isBucketed: true,
sourceField: 'Records',
params: {
filters: [
{
@ -112,34 +111,14 @@ describe('filters', () => {
});
});
describe('getPossibleOperationForField', () => {
describe('getPossibleOperation', () => {
it('should return operation with the right type for document', () => {
expect(
filtersOperation.getPossibleOperationForField({
aggregatable: true,
searchable: true,
name: 'test',
displayName: 'test',
type: 'document',
})
).toEqual({
expect(filtersOperation.getPossibleOperation()).toEqual({
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
});
});
it('should not return operation if field type is not document', () => {
expect(
filtersOperation.getPossibleOperationForField({
aggregatable: false,
searchable: true,
name: 'test',
displayName: 'test',
type: 'string',
})
).toEqual(undefined);
});
});
describe('popover param editor', () => {

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiLink, htmlIdGenerator } from '@elastic/eui';
import { updateColumnParam } from '../../../state_helpers';
import { OperationDefinition } from '../index';
import { FieldBasedIndexPatternColumn } from '../column_types';
import { BaseIndexPatternColumn } from '../column_types';
import { FilterPopover } from './filter_popover';
import { IndexPattern } from '../../../types';
import { Query, esKuery, esQuery } from '../../../../../../../../src/plugins/data/public';
@ -61,31 +61,22 @@ export const isQueryValid = (input: Query, indexPattern: IndexPattern) => {
}
};
export interface FiltersIndexPatternColumn extends FieldBasedIndexPatternColumn {
export interface FiltersIndexPatternColumn extends BaseIndexPatternColumn {
operationType: 'filters';
params: {
filters: Filter[];
};
}
export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn> = {
export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn, 'none'> = {
type: 'filters',
displayName: filtersLabel,
priority: 3, // Higher than any metric
getPossibleOperationForField: ({ type }) => {
if (type === 'document') {
return {
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
};
}
},
isTransferable: () => false,
onFieldChange: (oldColumn, indexPattern, field) => oldColumn,
input: 'none',
isTransferable: () => true,
buildColumn({ suggestedPriority, field, previousColumn }) {
buildColumn({ suggestedPriority, previousColumn }) {
let params = { filters: [defaultFilter] };
if (previousColumn?.operationType === 'terms') {
params = {
@ -108,11 +99,18 @@ export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn> =
scale: 'ordinal',
suggestedPriority,
isBucketed: true,
sourceField: field.name,
params,
};
},
getPossibleOperation() {
return {
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
};
},
toEsAggsConfig: (column, columnId, indexPattern) => {
const validFilters = column.params.filters?.filter((f: Filter) =>
isQueryValid(f.input, indexPattern)

View file

@ -28,22 +28,6 @@ import { DateRange } from '../../../../common';
import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public';
import { RangeIndexPatternColumn, rangeOperation } from './ranges';
// List of all operation definitions registered to this data source.
// If you want to implement a new operation, add the definition to this array and
// the column type to the `IndexPatternColumn` union type below.
const internalOperationDefinitions = [
filtersOperation,
termsOperation,
dateHistogramOperation,
minOperation,
maxOperation,
averageOperation,
cardinalityOperation,
sumOperation,
countOperation,
rangeOperation,
];
/**
* A union type of all available column types. If a column is of an unknown type somewhere
* withing the indexpattern data source it should be typed as `IndexPatternColumn` to make
@ -61,6 +45,24 @@ export type IndexPatternColumn =
| SumIndexPatternColumn
| CountIndexPatternColumn;
export type FieldBasedIndexPatternColumn = Extract<IndexPatternColumn, { sourceField: string }>;
// List of all operation definitions registered to this data source.
// If you want to implement a new operation, add the definition to this array and
// the column type to the `IndexPatternColumn` union type below.
const internalOperationDefinitions = [
filtersOperation,
termsOperation,
dateHistogramOperation,
minOperation,
maxOperation,
averageOperation,
cardinalityOperation,
sumOperation,
countOperation,
rangeOperation,
];
export { termsOperation } from './terms';
export { rangeOperation } from './ranges';
export { filtersOperation } from './filters';
@ -71,7 +73,7 @@ export { countOperation } from './count';
/**
* Properties passed to the operation-specific part of the popover editor
*/
export interface ParamEditorProps<C extends BaseIndexPatternColumn> {
export interface ParamEditorProps<C> {
currentColumn: C;
state: IndexPatternPrivateState;
setState: StateSetter<IndexPatternPrivateState>;
@ -138,13 +140,25 @@ interface BaseBuildColumnArgs {
indexPattern: IndexPattern;
}
/**
* Shape of an operation definition. If the type parameter of the definition
* indicates a field based column, `getPossibleOperationForField` has to be
* specified, otherwise `getPossibleOperationForDocument` has to be defined.
*/
export interface OperationDefinition<C extends BaseIndexPatternColumn>
extends BaseOperationDefinitionProps<C> {
interface FieldlessOperationDefinition<C extends BaseIndexPatternColumn> {
input: 'none';
/**
* Builds the column object for the given parameters. Should include default p
*/
buildColumn: (
arg: BaseBuildColumnArgs & {
previousColumn?: IndexPatternColumn;
}
) => C;
/**
* Returns the meta data of the operation if applied. Undefined
* if the field is not applicable.
*/
getPossibleOperation: () => OperationMetadata | undefined;
}
interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> {
input: 'field';
/**
* Returns the meta data of the operation if applied to the given field. Undefined
* if the field is not applicable to the operation.
@ -156,7 +170,8 @@ export interface OperationDefinition<C extends BaseIndexPatternColumn>
buildColumn: (
arg: BaseBuildColumnArgs & {
field: IndexPatternField;
previousColumn?: IndexPatternColumn;
// previousColumn?: IndexPatternColumn;
previousColumn?: C;
}
) => C;
/**
@ -175,9 +190,29 @@ export interface OperationDefinition<C extends BaseIndexPatternColumn>
* @param indexPattern The index pattern that field is on.
* @param field The field that the user changed to.
*/
onFieldChange: (oldColumn: C, indexPattern: IndexPattern, field: IndexPatternField) => C;
onFieldChange: (
// oldColumn: FieldBasedIndexPatternColumn,
oldColumn: C,
indexPattern: IndexPattern,
field: IndexPatternField
) => C;
}
interface OperationDefinitionMap<C extends BaseIndexPatternColumn> {
field: FieldBasedOperationDefinition<C>;
none: FieldlessOperationDefinition<C>;
}
/**
* Shape of an operation definition. If the type parameter of the definition
* indicates a field based column, `getPossibleOperationForField` has to be
* specified, otherwise `getPossibleOperation` has to be defined.
*/
export type OperationDefinition<
C extends BaseIndexPatternColumn,
Input extends keyof OperationDefinitionMap<C>
> = BaseOperationDefinitionProps<C> & OperationDefinitionMap<C>[Input];
/**
* A union type of all available operation types. The operation type is a unique id of an operation.
* Each column is assigned to exactly one operation type.
@ -188,7 +223,9 @@ export type OperationType = typeof internalOperationDefinitions[number]['type'];
* This is an operation definition of an unspecified column out of all possible
* column types.
*/
export type GenericOperationDefinition = OperationDefinition<IndexPatternColumn>;
export type GenericOperationDefinition =
| OperationDefinition<IndexPatternColumn, 'field'>
| OperationDefinition<IndexPatternColumn, 'none'>;
/**
* List of all available operation definitions
@ -206,7 +243,10 @@ export const operationDefinitions = internalOperationDefinitions as GenericOpera
* (e.g. `import { termsOperation } from './operations/definitions'`). This map is
* intended to be used in situations where the operation type is not known during compile time.
*/
export const operationDefinitionMap = internalOperationDefinitions.reduce(
export const operationDefinitionMap: Record<
string,
GenericOperationDefinition
> = internalOperationDefinitions.reduce(
(definitionMap, definition) => ({ ...definitionMap, [definition.type]: definition }),
{}
) as Record<OperationType, GenericOperationDefinition>;
);

View file

@ -6,11 +6,12 @@
import { i18n } from '@kbn/i18n';
import { OperationDefinition } from './index';
import { FormattedIndexPatternColumn } from './column_types';
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
type MetricColumn<T> = FormattedIndexPatternColumn & {
operationType: T;
};
type MetricColumn<T> = FormattedIndexPatternColumn &
FieldBasedIndexPatternColumn & {
operationType: T;
};
function buildMetricOperation<T extends MetricColumn<string>>({
type,
@ -27,6 +28,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({
type,
priority,
displayName,
input: 'field',
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => {
if (
fieldType === 'number' &&
@ -78,7 +80,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({
missing: 0,
},
}),
} as OperationDefinition<T>;
} as OperationDefinition<T, 'field'>;
}
export type SumIndexPatternColumn = MetricColumn<'sum'>;

View file

@ -76,12 +76,13 @@ function getEsAggsParams({ sourceField, params }: RangeIndexPatternColumn) {
};
}
export const rangeOperation: OperationDefinition<RangeIndexPatternColumn> = {
export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field'> = {
type: 'range',
displayName: i18n.translate('xpack.lens.indexPattern.ranges', {
defaultMessage: 'Ranges',
}),
priority: 4, // Higher than terms, so numbers get histogram
input: 'field',
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
type === 'number' &&

View file

@ -48,12 +48,13 @@ export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn {
};
}
export const termsOperation: OperationDefinition<TermsIndexPatternColumn> = {
export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field'> = {
type: 'terms',
displayName: i18n.translate('xpack.lens.indexPattern.terms', {
defaultMessage: 'Top values',
}),
priority: 3, // Higher than any metric
input: 'field',
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
supportedTypes.has(type) &&
@ -95,23 +96,25 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn> = {
},
};
},
toEsAggsConfig: (column, columnId, _indexPattern) => ({
id: columnId,
enabled: true,
type: 'terms',
schema: 'segment',
params: {
field: column.sourceField,
orderBy:
column.params.orderBy.type === 'alphabetical' ? '_key' : column.params.orderBy.columnId,
order: column.params.orderDirection,
size: column.params.size,
otherBucket: false,
otherBucketLabel: 'Other',
missingBucket: false,
missingBucketLabel: 'Missing',
},
}),
toEsAggsConfig: (column, columnId, _indexPattern) => {
return {
id: columnId,
enabled: true,
type: 'terms',
schema: 'segment',
params: {
field: column.sourceField,
orderBy:
column.params.orderBy.type === 'alphabetical' ? '_key' : column.params.orderBy.columnId,
order: column.params.orderDirection,
size: column.params.size,
otherBucket: false,
otherBucketLabel: 'Other',
missingBucket: false,
missingBucketLabel: 'Missing',
},
};
},
onFieldChange: (oldColumn, indexPattern, field) => {
return {
...oldColumn,

View file

@ -5,4 +5,4 @@
*/
export * from './operations';
export { OperationType, IndexPatternColumn } from './definitions';
export { OperationType, IndexPatternColumn, FieldBasedIndexPatternColumn } from './definitions';

View file

@ -182,7 +182,7 @@ describe('getOperationTypesForField', () => {
},
};
it('should build a column for the given operation type if it is passed in', () => {
it('should build a column for the given field-based operation type if it is passed in', () => {
const column = buildColumn({
layerId: 'first',
indexPattern: expectedIndexPatterns[1],
@ -194,6 +194,17 @@ describe('getOperationTypesForField', () => {
expect(column.operationType).toEqual('count');
});
it('should build a column for the given no-input operation type if it is passed in', () => {
const column = buildColumn({
layerId: 'first',
indexPattern: expectedIndexPatterns[1],
columns: state.layers.first.columns,
suggestedPriority: 0,
op: 'filters',
});
expect(column.operationType).toEqual('filters');
});
it('should build a column for the given operation type and field if it is passed in', () => {
const field = expectedIndexPatterns[1].fields[1];
const column = buildColumn({
@ -222,7 +233,7 @@ describe('getOperationTypesForField', () => {
);
});
it('should list out all field-operation tuples for different operation meta data', () => {
it('should list out all operation tuples', () => {
expect(getAvailableOperationsByMetadata(expectedIndexPatterns[1])).toMatchInlineSnapshot(`
Array [
Object {
@ -255,13 +266,17 @@ describe('getOperationTypesForField', () => {
},
Object {
"operationMetaData": Object {
"dataType": "number",
"dataType": "string",
"isBucketed": true,
"scale": "ordinal",
},
"operations": Array [
Object {
"field": "bytes",
"operationType": "filters",
"type": "none",
},
Object {
"field": "source",
"operationType": "terms",
"type": "field",
},
@ -269,13 +284,13 @@ describe('getOperationTypesForField', () => {
},
Object {
"operationMetaData": Object {
"dataType": "string",
"dataType": "number",
"isBucketed": true,
"scale": "ordinal",
},
"operations": Array [
Object {
"field": "source",
"field": "bytes",
"operationType": "terms",
"type": "field",
},

View file

@ -63,7 +63,7 @@ export function getOperationTypesForField(field: IndexPatternField): OperationTy
return operationDefinitions
.filter(
(operationDefinition) =>
'getPossibleOperationForField' in operationDefinition &&
operationDefinition.input === 'field' &&
operationDefinition.getPossibleOperationForField(field)
)
.sort(getSortScoreByPriority)
@ -80,11 +80,16 @@ export function isDocumentOperation(type: string) {
return documentOperations.has(type);
}
interface OperationFieldTuple {
type: 'field';
operationType: OperationType;
field: string;
}
type OperationFieldTuple =
| {
type: 'field';
operationType: OperationType;
field: string;
}
| {
type: 'none';
operationType: OperationType;
};
/**
* Returns all possible operations (matches between operations and fields of the index
@ -100,11 +105,18 @@ interface OperationFieldTuple {
* [
* {
* operationMetaData: { dataType: 'string', isBucketed: true },
* operations: ['terms']
* operations: [{
* type: 'field',
* operationType: ['terms'],
* field: 'keyword'
* }]
* },
* {
* operationMetaData: { dataType: 'number', isBucketed: false },
* operations: ['avg', 'min', 'max']
* operationMetaData: { dataType: 'string', isBucketed: true },
* operations: [{
* type: 'none',
* operationType: ['filters'],
* }]
* },
* ]
* ```
@ -133,30 +145,31 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) {
};
operationDefinitions.sort(getSortScoreByPriority).forEach((operationDefinition) => {
indexPattern.fields.forEach((field) => {
if (operationDefinition.input === 'field') {
indexPattern.fields.forEach((field) => {
addToMap(
{
type: 'field',
operationType: operationDefinition.type,
field: field.name,
},
operationDefinition.getPossibleOperationForField(field)
);
});
} else if (operationDefinition.input === 'none') {
addToMap(
{
type: 'field',
type: 'none',
operationType: operationDefinition.type,
field: field.name,
},
getPossibleOperationForField(operationDefinition, field)
operationDefinition.getPossibleOperation()
);
});
}
});
return Object.values(operationByMetadata);
}
function getPossibleOperationForField(
operationDefinition: GenericOperationDefinition,
field: IndexPatternField
): OperationMetadata | undefined {
return 'getPossibleOperationForField' in operationDefinition
? operationDefinition.getPossibleOperationForField(field)
: undefined;
}
/**
* Changes the field of the passed in colum. To do so, this method uses the `onFieldChange` function of
* the operation definition of the column. Returns a new column object with the field changed.
@ -171,13 +184,13 @@ export function changeField(
) {
const operationDefinition = operationDefinitionMap[column.operationType];
if (!('onFieldChange' in operationDefinition)) {
if (operationDefinition.input === 'field' && 'sourceField' in column) {
return operationDefinition.onFieldChange(column, indexPattern, newField);
} else {
throw new Error(
"Invariant error: Cannot change field if operation isn't a field based operaiton"
);
}
return operationDefinition.onFieldChange(column, indexPattern, newField);
}
/**
@ -203,7 +216,7 @@ export function buildColumn({
suggestedPriority: DimensionPriority | undefined;
layerId: string;
indexPattern: IndexPattern;
field: IndexPatternField;
field?: IndexPatternField;
previousColumn?: IndexPatternColumn;
}): IndexPatternColumn {
const operationDefinition = operationDefinitionMap[op];
@ -220,16 +233,18 @@ export function buildColumn({
previousColumn,
};
if (operationDefinition.input === 'none') {
return operationDefinition.buildColumn(baseOptions);
}
if (!field) {
throw new Error(`Invariant error: ${operationDefinition.type} operation requires field`);
}
const newColumn = operationDefinition.buildColumn({
return operationDefinition.buildColumn({
...baseOptions,
field,
});
return newColumn;
}
export { operationDefinitionMap } from './definitions';

View file

@ -5,8 +5,7 @@
*/
import _ from 'lodash';
import { isColumnTransferable } from './operations';
import { operationDefinitionMap, IndexPatternColumn } from './operations';
import { isColumnTransferable, operationDefinitionMap, IndexPatternColumn } from './operations';
import { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from './types';
export function updateColumnParam<C extends IndexPatternColumn, K extends keyof C['params']>({

View file

@ -67,11 +67,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// legend item(s), so we're using a class selector here.
expect(await find.allByCssSelector('.echLegendItem')).to.have.length(3);
});
it('should create an xy visualization with filters aggregation', async () => {
await PageObjects.visualize.gotoVisualizationLandingPage();
await listingTable.searchForItemWithName('lnsXYvis');
await PageObjects.lens.clickVisualizeListItemTitle('lnsXYvis');
await PageObjects.lens.goToTimeRange();
// Change the IP field to filters
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_splitDimensionPanel > lns-dimensionTrigger',
operation: 'filters',
@ -79,6 +81,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
await PageObjects.lens.addFilterToAgg(`geo.src : CN`);
// Verify that the field was persisted from the transition
expect(await PageObjects.lens.getFiltersAggLabels()).to.eql([`ip : *`, `geo.src : CN`]);
expect(await find.allByCssSelector('.echLegendItem')).to.have.length(2);
});