[Lens] Split up dimension panel code (#80423)

* [Lens] Split up dimension panel code

* Fix test failures

* Style updates

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Wylie Conlon 2020-10-19 10:51:21 -04:00 committed by GitHub
parent f68e0a36d5
commit f0023ed879
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1855 additions and 1666 deletions

View file

@ -17,4 +17,5 @@ export const {
sortByField,
hasField,
updateLayerIndexPattern,
mergeLayer,
} = actual;

View file

@ -16,7 +16,8 @@ import {
EuiListGroupItemProps,
EuiFormLabel,
} from '@elastic/eui';
import { IndexPatternDimensionEditorProps, OperationSupportMatrix } from './dimension_panel';
import { IndexPatternDimensionEditorProps } from './dimension_panel';
import { OperationSupportMatrix } from './operation_support';
import { IndexPatternColumn, OperationType } from '../indexpattern';
import {
operationDefinitionMap,
@ -24,7 +25,7 @@ import {
buildColumn,
changeField,
} from '../operations';
import { deleteColumn, changeColumn, updateColumnParam } from '../state_helpers';
import { deleteColumn, changeColumn, updateColumnParam, mergeLayer } from '../state_helpers';
import { FieldSelect } from './field_select';
import { hasField, fieldIsInvalid } from '../utils';
import { BucketNestingEditor } from './bucket_nesting_editor';
@ -394,12 +395,11 @@ export function DimensionEditor(props: DimensionEditorProps) {
<LabelInput
value={selectedColumn.label}
onChange={(value) => {
setState({
...state,
layers: {
...state.layers,
[layerId]: {
...state.layers[layerId],
setState(
mergeLayer({
state,
layerId,
newLayer: {
columns: {
...state.layers[layerId].columns,
[columnId]: {
@ -409,8 +409,8 @@ export function DimensionEditor(props: DimensionEditorProps) {
},
},
},
},
});
})
);
}}
/>
)}

View file

@ -4,28 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiLink, EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import {
DatasourceDimensionTriggerProps,
DatasourceDimensionEditorProps,
DatasourceDimensionDropProps,
DatasourceDimensionDropHandlerProps,
isDraggedOperation,
} from '../../types';
import { DatasourceDimensionTriggerProps, DatasourceDimensionEditorProps } from '../../types';
import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public';
import { IndexPatternColumn, OperationType } from '../indexpattern';
import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations';
import { IndexPatternColumn } from '../indexpattern';
import { fieldIsInvalid } from '../utils';
import { IndexPatternPrivateState } from '../types';
import { DimensionEditor } from './dimension_editor';
import { changeColumn } from '../state_helpers';
import { isDraggedField, hasField, fieldIsInvalid } from '../utils';
import { IndexPatternPrivateState, IndexPatternField } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { DateRange } from '../../../common';
import { getOperationSupportMatrix } from './operation_support';
export type IndexPatternDimensionTriggerProps = DatasourceDimensionTriggerProps<
IndexPatternPrivateState
@ -46,189 +37,6 @@ export type IndexPatternDimensionEditorProps = DatasourceDimensionEditorProps<
dateRange: DateRange;
};
export interface OperationSupportMatrix {
operationByField: Partial<Record<string, OperationType[]>>;
operationWithoutField: OperationType[];
fieldByOperation: Partial<Record<OperationType, string[]>>;
}
type Props = Pick<
DatasourceDimensionDropProps<IndexPatternPrivateState>,
'layerId' | 'columnId' | 'state' | 'filterOperations'
>;
// 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 getOperationSupportMatrix = (props: Props): OperationSupportMatrix => {
const layerId = props.layerId;
const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId];
const filteredOperationsByMetadata = getAvailableOperationsByMetadata(
currentIndexPattern
).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 (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];
}
} 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 operationSupportMatrix = getOperationSupportMatrix(props);
const { dragging } = props.dragDropContext;
const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId;
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationSupportMatrix.operationByField[field.name]);
}
if (isDraggedField(dragging)) {
return (
layerIndexPatternId === dragging.indexPatternId &&
Boolean(hasOperationForField(dragging.field))
);
}
if (
isDraggedOperation(dragging) &&
dragging.layerId === props.layerId &&
props.columnId !== dragging.columnId
) {
const op = props.state.layers[props.layerId].columns[dragging.columnId];
return props.filterOperations(op);
}
return false;
}
export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) {
const operationSupportMatrix = getOperationSupportMatrix(props);
const droppedItem = props.droppedItem;
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationSupportMatrix.operationByField[field.name]);
}
if (isDraggedOperation(droppedItem) && droppedItem.layerId === props.layerId) {
const layer = props.state.layers[props.layerId];
const op = { ...layer.columns[droppedItem.columnId] };
if (!props.filterOperations(op)) {
return false;
}
const newColumns = { ...layer.columns };
delete newColumns[droppedItem.columnId];
newColumns[props.columnId] = op;
const newColumnOrder = [...layer.columnOrder];
const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId);
const newIndex = newColumnOrder.findIndex((c) => c === props.columnId);
if (newIndex === -1) {
newColumnOrder[oldIndex] = props.columnId;
} else {
newColumnOrder.splice(oldIndex, 1);
}
// Time to replace
props.setState({
...props.state,
layers: {
...props.state.layers,
[props.layerId]: {
...layer,
columnOrder: newColumnOrder,
columns: newColumns,
},
},
});
return { deleted: droppedItem.columnId };
}
if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) {
// TODO: What do we do if we couldn't find a column?
return false;
}
const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name];
const layerId = props.layerId;
const selectedColumn: IndexPatternColumn | null =
props.state.layers[layerId].columns[props.columnId] || null;
const currentIndexPattern =
props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId];
// We need to check if dragging in a new field, was just a field change on the same
// index pattern and on the same operations (therefore checking if the new field supports
// our previous operation)
const hasFieldChanged =
selectedColumn &&
hasField(selectedColumn) &&
selectedColumn.sourceField !== droppedItem.field.name &&
operationsForNewField &&
operationsForNewField.includes(selectedColumn.operationType);
if (!operationsForNewField || operationsForNewField.length === 0) {
return false;
}
// If only the field has changed use the onFieldChange method on the operation to get the
// new column, otherwise use the regular buildColumn to get a new column.
const newColumn = hasFieldChanged
? changeField(selectedColumn, currentIndexPattern, droppedItem.field)
: buildColumn({
op: operationsForNewField[0],
columns: props.state.layers[props.layerId].columns,
indexPattern: currentIndexPattern,
layerId,
suggestedPriority: props.suggestedPriority,
field: droppedItem.field,
previousColumn: selectedColumn,
});
trackUiEvent('drop_onto_dimension');
const hasData = Object.values(props.state.layers).some(({ columns }) => columns.length);
trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty');
props.setState(
changeColumn({
state: props.state,
layerId,
columnId: props.columnId,
newColumn,
// If the field has changed, the onFieldChange method needs to take care of everything including moving
// over params. If we create a new column above we want changeColumn to move over params.
keepParams: !hasFieldChanged,
})
);
return true;
}
function wrapOnDot(str?: string) {
// u200B is a non-width white-space character, which allows
// the browser to efficiently word-wrap right after the dot

View file

@ -0,0 +1,594 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public';
import { IndexPatternDimensionEditorProps } from './dimension_panel';
import { onDrop, canHandleDrop } from './droppable';
import { DragContextState } from '../../drag_drop';
import { createMockedDragDropContext } from '../mocks';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { IndexPatternPrivateState } from '../types';
import { documentField } from '../document_field';
import { OperationMetadata } from '../../types';
jest.mock('../state_helpers');
const expectedIndexPatterns = {
1: {
id: '1',
title: 'my-fake-index-pattern',
timeFieldName: 'timestamp',
hasExistence: true,
hasRestrictions: false,
fields: [
{
name: 'timestamp',
displayName: 'timestampLabel',
type: 'date',
aggregatable: true,
searchable: true,
exists: true,
},
{
name: 'bytes',
displayName: 'bytes',
type: 'number',
aggregatable: true,
searchable: true,
exists: true,
},
{
name: 'memory',
displayName: 'memory',
type: 'number',
aggregatable: true,
searchable: true,
exists: true,
},
{
name: 'source',
displayName: 'source',
type: 'string',
aggregatable: true,
searchable: true,
exists: true,
},
documentField,
],
},
};
/**
* 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;
beforeEach(() => {
state = {
indexPatternRefs: [],
indexPatterns: expectedIndexPatterns,
currentIndexPatternId: '1',
isFirstExistenceFetch: false,
existingFields: {
'my-fake-index-pattern': {
timestamp: true,
bytes: true,
memory: true,
source: true,
},
},
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Date histogram of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'date_histogram',
params: {
interval: '1d',
},
sourceField: 'timestamp',
},
},
},
},
};
setState = jest.fn();
dragDropContext = createMockedDragDropContext();
defaultProps = {
state,
setState,
dateRange: { fromDate: 'now-1d', toDate: 'now' },
columnId: 'col1',
layerId: 'first',
uniqueLabel: 'stuff',
filterOperations: () => true,
storage: {} as IStorageWrapper,
uiSettings: {} as IUiSettingsClient,
savedObjectsClient: {} as SavedObjectsClientContract,
http: {} as HttpSetup,
data: ({
fieldFormats: ({
getType: jest.fn().mockReturnValue({
id: 'number',
title: 'Number',
}),
getDefaultType: jest.fn().mockReturnValue({
id: 'bytes',
title: 'Bytes',
}),
} as unknown) as DataPublicPluginStart['fieldFormats'],
} as unknown) as DataPublicPluginStart,
core: {} as CoreSetup,
};
jest.clearAllMocks();
});
function dragDropState(): IndexPatternPrivateState {
return {
indexPatternRefs: [],
existingFields: {},
indexPatterns: {
foo: {
id: 'foo',
title: 'Foo pattern',
hasRestrictions: false,
fields: [
{
aggregatable: true,
name: 'bar',
displayName: 'bar',
searchable: true,
type: 'number',
},
{
aggregatable: true,
name: 'mystring',
displayName: 'mystring',
searchable: true,
type: 'string',
},
],
},
},
currentIndexPatternId: '1',
isFirstExistenceFetch: false,
layers: {
myLayer: {
indexPatternId: 'foo',
columnOrder: ['col1'],
columns: {
col1: {
label: 'Date histogram of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'date_histogram',
params: {
interval: '1d',
},
sourceField: 'timestamp',
},
},
},
},
};
}
it('is not droppable if no drag is happening', () => {
expect(
canHandleDrop({
...defaultProps,
dragDropContext,
state: dragDropState(),
layerId: 'myLayer',
})
).toBe(false);
});
it('is not droppable if the dragged item has no field', () => {
expect(
canHandleDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging: { name: 'bar' },
},
})
).toBe(false);
});
it('is not droppable if field is not supported by filterOperations', () => {
expect(
canHandleDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging: {
indexPatternId: 'foo',
field: { type: 'string', name: 'mystring', aggregatable: true },
},
},
state: dragDropState(),
filterOperations: () => false,
layerId: 'myLayer',
})
).toBe(false);
});
it('is droppable if the field is supported by filterOperations', () => {
expect(
canHandleDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging: {
field: { type: 'number', name: 'bar', aggregatable: true },
indexPatternId: 'foo',
},
},
state: dragDropState(),
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
layerId: 'myLayer',
})
).toBe(true);
});
it('is not droppable if the field belongs to another index pattern', () => {
expect(
canHandleDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging: {
field: { type: 'number', name: 'bar', aggregatable: true },
indexPatternId: 'foo2',
},
},
state: dragDropState(),
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
layerId: 'myLayer',
})
).toBe(false);
});
it('is droppable if the dragged column is compatible', () => {
expect(
canHandleDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging: {
columnId: 'col1',
groupId: 'a',
layerId: 'myLayer',
},
},
state: dragDropState(),
columnId: 'col2',
filterOperations: (op: OperationMetadata) => true,
layerId: 'myLayer',
})
).toBe(true);
});
it('is not droppable if the dragged column is the same as the current column', () => {
expect(
canHandleDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging: {
columnId: 'col1',
groupId: 'a',
layerId: 'myLayer',
},
},
state: dragDropState(),
columnId: 'col1',
filterOperations: (op: OperationMetadata) => true,
layerId: 'myLayer',
})
).toBe(false);
});
it('is not droppable if the dragged column is incompatible', () => {
expect(
canHandleDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging: {
columnId: 'col1',
groupId: 'a',
layerId: 'myLayer',
},
},
state: dragDropState(),
columnId: 'col2',
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
layerId: 'myLayer',
})
).toBe(false);
});
it('appends the dropped column when a field is dropped', () => {
const dragging = {
field: { type: 'number', name: 'bar', aggregatable: true },
indexPatternId: 'foo',
};
const testState = dragDropState();
onDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging,
},
droppedItem: dragging,
state: testState,
columnId: 'col2',
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
layerId: 'myLayer',
});
expect(setState).toBeCalledTimes(1);
expect(setState).toHaveBeenCalledWith({
...testState,
layers: {
myLayer: {
...testState.layers.myLayer,
columnOrder: ['col1', 'col2'],
columns: {
...testState.layers.myLayer.columns,
col2: expect.objectContaining({
dataType: 'number',
sourceField: 'bar',
}),
},
},
},
});
});
it('selects the specific operation that was valid on drop', () => {
const dragging = {
field: { type: 'string', name: 'mystring', aggregatable: true },
indexPatternId: 'foo',
};
const testState = dragDropState();
onDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging,
},
droppedItem: dragging,
state: testState,
columnId: 'col2',
filterOperations: (op: OperationMetadata) => op.isBucketed,
layerId: 'myLayer',
});
expect(setState).toBeCalledTimes(1);
expect(setState).toHaveBeenCalledWith({
...testState,
layers: {
myLayer: {
...testState.layers.myLayer,
columnOrder: ['col1', 'col2'],
columns: {
...testState.layers.myLayer.columns,
col2: expect.objectContaining({
dataType: 'string',
sourceField: 'mystring',
}),
},
},
},
});
});
it('updates a column when a field is dropped', () => {
const dragging = {
field: { type: 'number', name: 'bar', aggregatable: true },
indexPatternId: 'foo',
};
const testState = dragDropState();
onDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging,
},
droppedItem: dragging,
state: testState,
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
layerId: 'myLayer',
});
expect(setState).toBeCalledTimes(1);
expect(setState).toHaveBeenCalledWith({
...testState,
layers: {
myLayer: expect.objectContaining({
columns: expect.objectContaining({
col1: expect.objectContaining({
dataType: 'number',
sourceField: 'bar',
}),
}),
}),
},
});
});
it('does not set the size of the terms aggregation', () => {
const dragging = {
field: { type: 'string', name: 'mystring', aggregatable: true },
indexPatternId: 'foo',
};
const testState = dragDropState();
onDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging,
},
droppedItem: dragging,
state: testState,
columnId: 'col2',
filterOperations: (op: OperationMetadata) => op.isBucketed,
layerId: 'myLayer',
});
expect(setState).toBeCalledTimes(1);
expect(setState).toHaveBeenCalledWith({
...testState,
layers: {
myLayer: {
...testState.layers.myLayer,
columnOrder: ['col1', 'col2'],
columns: {
...testState.layers.myLayer.columns,
col2: expect.objectContaining({
operationType: 'terms',
params: expect.objectContaining({ size: 3 }),
}),
},
},
},
});
});
it('updates the column id when moving an operation to an empty dimension', () => {
const dragging = {
columnId: 'col1',
groupId: 'a',
layerId: 'myLayer',
};
const testState = dragDropState();
onDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging,
},
droppedItem: dragging,
state: testState,
columnId: 'col2',
filterOperations: (op: OperationMetadata) => true,
layerId: 'myLayer',
});
expect(setState).toBeCalledTimes(1);
expect(setState).toHaveBeenCalledWith({
...testState,
layers: {
myLayer: {
...testState.layers.myLayer,
columnOrder: ['col2'],
columns: {
col2: testState.layers.myLayer.columns.col1,
},
},
},
});
});
it('replaces an operation when moving to a populated dimension', () => {
const dragging = {
columnId: 'col2',
groupId: 'a',
layerId: 'myLayer',
};
const testState = dragDropState();
testState.layers.myLayer = {
indexPatternId: 'foo',
columnOrder: ['col1', 'col2', 'col3'],
columns: {
col1: testState.layers.myLayer.columns.col1,
col2: {
label: 'Top values of src',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
params: {
orderBy: { type: 'column', columnId: 'col3' },
orderDirection: 'desc',
size: 10,
},
sourceField: 'src',
},
col3: {
label: 'Count',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'count',
sourceField: 'Records',
},
},
};
onDrop({
...defaultProps,
dragDropContext: {
...dragDropContext,
dragging,
},
droppedItem: dragging,
state: testState,
columnId: 'col1',
filterOperations: (op: OperationMetadata) => true,
layerId: 'myLayer',
});
expect(setState).toBeCalledTimes(1);
expect(setState).toHaveBeenCalledWith({
...testState,
layers: {
myLayer: {
...testState.layers.myLayer,
columnOrder: ['col1', 'col3'],
columns: {
col1: testState.layers.myLayer.columns.col2,
col3: testState.layers.myLayer.columns.col3,
},
},
},
});
});
});

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
DatasourceDimensionDropProps,
DatasourceDimensionDropHandlerProps,
isDraggedOperation,
} from '../../types';
import { IndexPatternColumn } from '../indexpattern';
import { buildColumn, changeField } from '../operations';
import { changeColumn, mergeLayer } from '../state_helpers';
import { isDraggedField, hasField } from '../utils';
import { IndexPatternPrivateState, IndexPatternField } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { getOperationSupportMatrix } from './operation_support';
export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPrivateState>) {
const operationSupportMatrix = getOperationSupportMatrix(props);
const { dragging } = props.dragDropContext;
const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId;
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationSupportMatrix.operationByField[field.name]);
}
if (isDraggedField(dragging)) {
return (
layerIndexPatternId === dragging.indexPatternId &&
Boolean(hasOperationForField(dragging.field))
);
}
if (
isDraggedOperation(dragging) &&
dragging.layerId === props.layerId &&
props.columnId !== dragging.columnId
) {
const op = props.state.layers[props.layerId].columns[dragging.columnId];
return props.filterOperations(op);
}
return false;
}
export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) {
const operationSupportMatrix = getOperationSupportMatrix(props);
const droppedItem = props.droppedItem;
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationSupportMatrix.operationByField[field.name]);
}
if (isDraggedOperation(droppedItem) && droppedItem.layerId === props.layerId) {
const layer = props.state.layers[props.layerId];
const op = { ...layer.columns[droppedItem.columnId] };
if (!props.filterOperations(op)) {
return false;
}
const newColumns = { ...layer.columns };
delete newColumns[droppedItem.columnId];
newColumns[props.columnId] = op;
const newColumnOrder = [...layer.columnOrder];
const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId);
const newIndex = newColumnOrder.findIndex((c) => c === props.columnId);
if (newIndex === -1) {
newColumnOrder[oldIndex] = props.columnId;
} else {
newColumnOrder.splice(oldIndex, 1);
}
// Time to replace
props.setState(
mergeLayer({
state: props.state,
layerId: props.layerId,
newLayer: {
columnOrder: newColumnOrder,
columns: newColumns,
},
})
);
return { deleted: droppedItem.columnId };
}
if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) {
// TODO: What do we do if we couldn't find a column?
return false;
}
const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name];
const layerId = props.layerId;
const selectedColumn: IndexPatternColumn | null =
props.state.layers[layerId].columns[props.columnId] || null;
const currentIndexPattern =
props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId];
// We need to check if dragging in a new field, was just a field change on the same
// index pattern and on the same operations (therefore checking if the new field supports
// our previous operation)
const hasFieldChanged =
selectedColumn &&
hasField(selectedColumn) &&
selectedColumn.sourceField !== droppedItem.field.name &&
operationsForNewField &&
operationsForNewField.includes(selectedColumn.operationType);
if (!operationsForNewField || operationsForNewField.length === 0) {
return false;
}
// If only the field has changed use the onFieldChange method on the operation to get the
// new column, otherwise use the regular buildColumn to get a new column.
const newColumn = hasFieldChanged
? changeField(selectedColumn, currentIndexPattern, droppedItem.field)
: buildColumn({
op: operationsForNewField[0],
columns: props.state.layers[props.layerId].columns,
indexPattern: currentIndexPattern,
layerId,
suggestedPriority: props.suggestedPriority,
field: droppedItem.field,
previousColumn: selectedColumn,
});
trackUiEvent('drop_onto_dimension');
const hasData = Object.values(props.state.layers).some(({ columns }) => columns.length);
trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty');
props.setState(
changeColumn({
state: props.state,
layerId,
columnId: props.columnId,
newColumn,
// If the field has changed, the onFieldChange method needs to take care of everything including moving
// over params. If we create a new column above we want changeColumn to move over params.
keepParams: !hasFieldChanged,
})
);
return true;
}

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 { OperationSupportMatrix } from './dimension_panel';
import { OperationSupportMatrix } from './operation_support';
import { IndexPattern, IndexPatternField, IndexPatternPrivateState } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { fieldExists } from '../pure_helpers';

View file

@ -5,3 +5,5 @@
*/
export * from './dimension_panel';
export * from './droppable';
export * from './operation_support';

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import { DatasourceDimensionDropProps } from '../../types';
import { OperationType } from '../indexpattern';
import { getAvailableOperationsByMetadata } from '../operations';
import { IndexPatternPrivateState } from '../types';
export interface OperationSupportMatrix {
operationByField: Partial<Record<string, OperationType[]>>;
operationWithoutField: OperationType[];
fieldByOperation: Partial<Record<OperationType, string[]>>;
}
type Props = Pick<
DatasourceDimensionDropProps<IndexPatternPrivateState>,
'layerId' | 'columnId' | 'state' | 'filterOperations'
>;
// TODO: This code has historically been memoized, as a potentially performance
// sensitive task. If we can add memoization without breaking the behavior, we should.
export const getOperationSupportMatrix = (props: Props): OperationSupportMatrix => {
const layerId = props.layerId;
const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId];
const filteredOperationsByMetadata = getAvailableOperationsByMetadata(
currentIndexPattern
).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 (operation.type === 'field') {
supportedOperationsByField[operation.field] = [
...(supportedOperationsByField[operation.field] ?? []),
operation.operationType,
];
supportedFieldsByOperation[operation.operationType] = [
...(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),
};
};

View file

@ -452,22 +452,32 @@ describe('state_helpers', () => {
describe('getColumnOrder', () => {
it('should work for empty columns', () => {
expect(getColumnOrder({})).toEqual([]);
expect(
getColumnOrder({
indexPatternId: '',
columnOrder: [],
columns: {},
})
).toEqual([]);
});
it('should work for one column', () => {
expect(
getColumnOrder({
col1: {
label: 'Value of timestamp',
dataType: 'string',
isBucketed: false,
columnOrder: [],
indexPatternId: '',
columns: {
col1: {
label: 'Value of timestamp',
dataType: 'string',
isBucketed: false,
// Private
operationType: 'date_histogram',
sourceField: 'timestamp',
params: {
interval: 'h',
// Private
operationType: 'date_histogram',
sourceField: 'timestamp',
params: {
interval: 'h',
},
},
},
})
@ -477,41 +487,45 @@ describe('state_helpers', () => {
it('should put any number of aggregations before metrics', () => {
expect(
getColumnOrder({
col1: {
label: 'Top values of category',
dataType: 'string',
isBucketed: true,
columnOrder: [],
indexPatternId: '',
columns: {
col1: {
label: 'Top values of category',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
sourceField: 'category',
params: {
size: 5,
orderBy: {
type: 'alphabetical',
// Private
operationType: 'terms',
sourceField: 'category',
params: {
size: 5,
orderBy: {
type: 'alphabetical',
},
orderDirection: 'asc',
},
orderDirection: 'asc',
},
},
col2: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
col2: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'avg',
sourceField: 'bytes',
},
col3: {
label: 'Date histogram of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'avg',
sourceField: 'bytes',
},
col3: {
label: 'Date histogram of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'date_histogram',
sourceField: 'timestamp',
params: {
interval: '1d',
// Private
operationType: 'date_histogram',
sourceField: 'timestamp',
params: {
interval: '1d',
},
},
},
})
@ -521,44 +535,48 @@ describe('state_helpers', () => {
it('should reorder aggregations based on suggested priority', () => {
expect(
getColumnOrder({
col1: {
label: 'Top values of category',
dataType: 'string',
isBucketed: true,
indexPatternId: '',
columnOrder: [],
columns: {
col1: {
label: 'Top values of category',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
sourceField: 'category',
params: {
size: 5,
orderBy: {
type: 'alphabetical',
// Private
operationType: 'terms',
sourceField: 'category',
params: {
size: 5,
orderBy: {
type: 'alphabetical',
},
orderDirection: 'asc',
},
orderDirection: 'asc',
suggestedPriority: 2,
},
suggestedPriority: 2,
},
col2: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
col2: {
label: 'Average of bytes',
dataType: 'number',
isBucketed: false,
// Private
operationType: 'avg',
sourceField: 'bytes',
suggestedPriority: 0,
},
col3: {
label: 'Date histogram of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'avg',
sourceField: 'bytes',
suggestedPriority: 0,
},
col3: {
label: 'Date histogram of timestamp',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'date_histogram',
sourceField: 'timestamp',
suggestedPriority: 1,
params: {
interval: '1d',
// Private
operationType: 'date_histogram',
sourceField: 'timestamp',
suggestedPriority: 1,
params: {
interval: '1d',
},
},
},
})

View file

@ -25,25 +25,24 @@ export function updateColumnParam<C extends IndexPatternColumn, K extends keyof
([_columnId, column]) => column === currentColumn
)![0];
return {
...state,
layers: {
...state.layers,
[layerId]: {
...state.layers[layerId],
columns: {
...state.layers[layerId].columns,
[columnId]: {
...currentColumn,
params: {
...currentColumn.params,
[paramName]: value,
},
const layer = state.layers[layerId];
return mergeLayer({
state,
layerId,
newLayer: {
columns: {
...layer.columns,
[columnId]: {
...currentColumn,
params: {
...currentColumn.params,
[paramName]: value,
},
},
},
},
};
});
}
function adjustColumnReferencesForChangedColumn(
@ -91,25 +90,29 @@ export function changeColumn<C extends IndexPatternColumn>({
updatedColumn.label = oldColumn.label;
}
const layer = {
...state.layers[layerId],
};
const newColumns = adjustColumnReferencesForChangedColumn(
{
...state.layers[layerId].columns,
...layer.columns,
[columnId]: updatedColumn,
},
columnId
);
return {
...state,
layers: {
...state.layers,
[layerId]: {
...state.layers[layerId],
columnOrder: getColumnOrder(newColumns),
return mergeLayer({
state,
layerId,
newLayer: {
columnOrder: getColumnOrder({
...layer,
columns: newColumns,
},
}),
columns: newColumns,
},
};
});
}
export function deleteColumn({
@ -125,24 +128,26 @@ export function deleteColumn({
delete hypotheticalColumns[columnId];
const newColumns = adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId);
return {
...state,
layers: {
...state.layers,
[layerId]: {
...state.layers[layerId],
columnOrder: getColumnOrder(newColumns),
columns: newColumns,
},
},
const layer = {
...state.layers[layerId],
columns: newColumns,
};
return mergeLayer({
state,
layerId,
newLayer: {
...layer,
columnOrder: getColumnOrder(layer),
},
});
}
export function getColumnOrder(columns: Record<string, IndexPatternColumn>): string[] {
const entries = Object.entries(columns);
const [aggregations, metrics] = _.partition(entries, ([id, col]) => col.isBucketed);
export function getColumnOrder(layer: IndexPatternLayer): string[] {
const [aggregations, metrics] = _.partition(
Object.entries(layer.columns),
([id, col]) => col.isBucketed
);
return aggregations
.sort(([id, col], [id2, col2]) => {
@ -156,6 +161,24 @@ export function getColumnOrder(columns: Record<string, IndexPatternColumn>): str
.concat(metrics.map(([id]) => id));
}
export function mergeLayer({
state,
layerId,
newLayer,
}: {
state: IndexPatternPrivateState;
layerId: string;
newLayer: Partial<IndexPatternLayer>;
}) {
return {
...state,
layers: {
...state.layers,
[layerId]: { ...state.layers[layerId], ...newLayer },
},
};
}
export function updateLayerIndexPattern(
layer: IndexPatternLayer,
newIndexPattern: IndexPattern