[Lens] Implement types for reference-based operations (#83603)
* [Lens] Implement types for reference-based operations * Update from review feedback
This commit is contained in:
parent
d31ee21a86
commit
b50e7ba7da
|
@ -410,7 +410,7 @@ describe('Datatable Visualization', () => {
|
|||
|
||||
const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame);
|
||||
|
||||
expect(error).not.toBeDefined();
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined if the metric dimension is defined', () => {
|
||||
|
@ -427,7 +427,7 @@ describe('Datatable Visualization', () => {
|
|||
|
||||
const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame);
|
||||
|
||||
expect(error).not.toBeDefined();
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -134,7 +134,7 @@ export const validateDatasourceAndVisualization = (
|
|||
? currentVisualization?.getErrorMessages(currentVisualizationState, frameAPI)
|
||||
: undefined;
|
||||
|
||||
if (datasourceValidationErrors || visualizationValidationErrors) {
|
||||
if (datasourceValidationErrors?.length || visualizationValidationErrors?.length) {
|
||||
return [...(datasourceValidationErrors || []), ...(visualizationValidationErrors || [])];
|
||||
}
|
||||
return undefined;
|
||||
|
|
|
@ -385,7 +385,7 @@ export const InnerVisualizationWrapper = ({
|
|||
[dispatch]
|
||||
);
|
||||
|
||||
if (localState.configurationValidationError) {
|
||||
if (localState.configurationValidationError?.length) {
|
||||
let showExtraErrors = null;
|
||||
if (localState.configurationValidationError.length > 1) {
|
||||
if (localState.expandError) {
|
||||
|
@ -445,7 +445,7 @@ export const InnerVisualizationWrapper = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (localState.expressionBuildError) {
|
||||
if (localState.expressionBuildError?.length) {
|
||||
return (
|
||||
<EuiFlexGroup style={{ maxWidth: '100%' }} direction="column" alignItems="center">
|
||||
<EuiFlexItem>
|
||||
|
|
|
@ -419,7 +419,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
function getErrorMessage(
|
||||
selectedColumn: IndexPatternColumn | undefined,
|
||||
incompatibleSelectedOperationType: boolean,
|
||||
input: 'none' | 'field' | undefined,
|
||||
input: 'none' | 'field' | 'fullReference' | undefined,
|
||||
fieldInvalid: boolean
|
||||
) {
|
||||
if (selectedColumn && incompatibleSelectedOperationType) {
|
||||
|
|
|
@ -1054,6 +1054,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
indexPatternId: '1',
|
||||
columns: {},
|
||||
columnOrder: [],
|
||||
incompleteColumns: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -21,6 +21,8 @@ type Props = Pick<
|
|||
'layerId' | 'columnId' | 'state' | 'filterOperations'
|
||||
>;
|
||||
|
||||
// TODO: the support matrix should be available outside of the dimension panel
|
||||
|
||||
// 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 => {
|
||||
|
|
|
@ -13,9 +13,15 @@ import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
|
|||
import { Ast } from '@kbn/interpreter/common';
|
||||
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
|
||||
import { getFieldByNameFactory } from './pure_helpers';
|
||||
import {
|
||||
operationDefinitionMap,
|
||||
getErrorMessages,
|
||||
createMockedReferenceOperation,
|
||||
} from './operations';
|
||||
|
||||
jest.mock('./loader');
|
||||
jest.mock('../id_generator');
|
||||
jest.mock('./operations');
|
||||
|
||||
const fieldsOne = [
|
||||
{
|
||||
|
@ -489,6 +495,56 @@ describe('IndexPattern Data Source', () => {
|
|||
expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']);
|
||||
expect(ast.chain[0].arguments.timeFields).not.toContain('timefield');
|
||||
});
|
||||
|
||||
describe('references', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error we are inserting an invalid type
|
||||
operationDefinitionMap.testReference = createMockedReferenceOperation();
|
||||
|
||||
// @ts-expect-error we are inserting an invalid type
|
||||
operationDefinitionMap.testReference.toExpression.mockReturnValue(['mock']);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete operationDefinitionMap.testReference;
|
||||
});
|
||||
|
||||
it('should collect expression references and append them', async () => {
|
||||
const queryBaseState: IndexPatternBaseState = {
|
||||
currentIndexPatternId: '1',
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Count of records',
|
||||
dataType: 'date',
|
||||
isBucketed: false,
|
||||
sourceField: 'timefield',
|
||||
operationType: 'cardinality',
|
||||
},
|
||||
col2: {
|
||||
label: 'Reference',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
// @ts-expect-error not a valid type
|
||||
operationType: 'testReference',
|
||||
references: ['col1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const state = enrichBaseState(queryBaseState);
|
||||
|
||||
const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
|
||||
// @ts-expect-error we can't isolate just the reference type
|
||||
expect(operationDefinitionMap.testReference.toExpression).toHaveBeenCalled();
|
||||
expect(ast.chain[2]).toEqual('mock');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#insertLayer', () => {
|
||||
|
@ -599,11 +655,33 @@ describe('IndexPattern Data Source', () => {
|
|||
|
||||
describe('getTableSpec', () => {
|
||||
it('should include col1', () => {
|
||||
expect(publicAPI.getTableSpec()).toEqual([
|
||||
{
|
||||
columnId: 'col1',
|
||||
expect(publicAPI.getTableSpec()).toEqual([{ columnId: 'col1' }]);
|
||||
});
|
||||
|
||||
it('should skip columns that are being referenced', () => {
|
||||
publicAPI = indexPatternDatasource.getPublicAPI({
|
||||
state: {
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
// @ts-ignore this is too little information for a real column
|
||||
col1: {
|
||||
dataType: 'number',
|
||||
},
|
||||
col2: {
|
||||
// @ts-expect-error update once we have a reference operation outside tests
|
||||
references: ['col1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
layerId: 'first',
|
||||
});
|
||||
|
||||
expect(publicAPI.getTableSpec()).toEqual([{ columnId: 'col2' }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -764,7 +842,7 @@ describe('IndexPattern Data Source', () => {
|
|||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'document',
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
|
@ -774,7 +852,7 @@ describe('IndexPattern Data Source', () => {
|
|||
};
|
||||
expect(
|
||||
indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState)
|
||||
).not.toBeDefined();
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return no errors with layers with no columns', () => {
|
||||
|
@ -792,7 +870,31 @@ describe('IndexPattern Data Source', () => {
|
|||
},
|
||||
currentIndexPatternId: '1',
|
||||
};
|
||||
expect(indexPatternDatasource.getErrorMessages(state)).not.toBeDefined();
|
||||
expect(indexPatternDatasource.getErrorMessages(state)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should bubble up invalid configuration from operations', () => {
|
||||
(getErrorMessages as jest.Mock).mockClear();
|
||||
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
|
||||
const state: IndexPatternPrivateState = {
|
||||
indexPatternRefs: [],
|
||||
existingFields: {},
|
||||
isFirstExistenceFetch: false,
|
||||
indexPatterns: expectedIndexPatterns,
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {},
|
||||
},
|
||||
},
|
||||
currentIndexPatternId: '1',
|
||||
};
|
||||
expect(indexPatternDatasource.getErrorMessages(state)).toEqual([
|
||||
{ shortMessage: 'error 1', longMessage: '' },
|
||||
{ shortMessage: 'error 2', longMessage: '' },
|
||||
]);
|
||||
expect(getErrorMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -40,13 +40,13 @@ import {
|
|||
} from './indexpattern_suggestions';
|
||||
|
||||
import {
|
||||
getInvalidFieldReferencesForLayer,
|
||||
getInvalidReferences,
|
||||
getInvalidFieldsForLayer,
|
||||
getInvalidLayers,
|
||||
isDraggedField,
|
||||
normalizeOperationDataType,
|
||||
} from './utils';
|
||||
import { LayerPanel } from './layerpanel';
|
||||
import { IndexPatternColumn } from './operations';
|
||||
import { IndexPatternColumn, getErrorMessages } from './operations';
|
||||
import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types';
|
||||
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
|
||||
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
|
||||
|
@ -54,7 +54,7 @@ import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/pub
|
|||
import { mergeLayer } from './state_helpers';
|
||||
import { Datasource, StateSetter } from '../index';
|
||||
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
|
||||
import { deleteColumn } from './operations';
|
||||
import { deleteColumn, isReferenced } from './operations';
|
||||
import { FieldBasedIndexPatternColumn } from './operations/definitions/column_types';
|
||||
import { Dragging } from '../drag_drop/providers';
|
||||
|
||||
|
@ -325,7 +325,9 @@ export function getIndexPatternDatasource({
|
|||
datasourceId: 'indexpattern',
|
||||
|
||||
getTableSpec: () => {
|
||||
return state.layers[layerId].columnOrder.map((colId) => ({ columnId: colId }));
|
||||
return state.layers[layerId].columnOrder
|
||||
.filter((colId) => !isReferenced(state.layers[layerId], colId))
|
||||
.map((colId) => ({ columnId: colId }));
|
||||
},
|
||||
getOperationForColumnId: (columnId: string) => {
|
||||
const layer = state.layers[layerId];
|
||||
|
@ -349,10 +351,17 @@ export function getIndexPatternDatasource({
|
|||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const invalidLayers = getInvalidReferences(state);
|
||||
const invalidLayers = getInvalidLayers(state);
|
||||
|
||||
const layerErrors = Object.values(state.layers).flatMap((layer) =>
|
||||
(getErrorMessages(layer) ?? []).map((message) => ({
|
||||
shortMessage: message,
|
||||
longMessage: '',
|
||||
}))
|
||||
);
|
||||
|
||||
if (invalidLayers.length === 0) {
|
||||
return;
|
||||
return layerErrors.length ? layerErrors : undefined;
|
||||
}
|
||||
|
||||
const realIndex = Object.values(state.layers)
|
||||
|
@ -363,64 +372,69 @@ export function getIndexPatternDatasource({
|
|||
}
|
||||
})
|
||||
.filter(Boolean) as Array<[number, number]>;
|
||||
const invalidFieldsPerLayer: string[][] = getInvalidFieldReferencesForLayer(
|
||||
const invalidFieldsPerLayer: string[][] = getInvalidFieldsForLayer(
|
||||
invalidLayers,
|
||||
state.indexPatterns
|
||||
);
|
||||
const originalLayersList = Object.keys(state.layers);
|
||||
|
||||
return realIndex.map(([filteredIndex, layerIndex]) => {
|
||||
const fieldsWithBrokenReferences: string[] = invalidFieldsPerLayer[filteredIndex].map(
|
||||
(columnId) => {
|
||||
const column = invalidLayers[filteredIndex].columns[
|
||||
columnId
|
||||
] as FieldBasedIndexPatternColumn;
|
||||
return column.sourceField;
|
||||
}
|
||||
);
|
||||
|
||||
if (originalLayersList.length === 1) {
|
||||
return {
|
||||
shortMessage: i18n.translate(
|
||||
'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer',
|
||||
{
|
||||
defaultMessage: 'Invalid {fields, plural, one {reference} other {references}}.',
|
||||
values: {
|
||||
fields: fieldsWithBrokenReferences.length,
|
||||
},
|
||||
if (layerErrors.length || realIndex.length) {
|
||||
return [
|
||||
...layerErrors,
|
||||
...realIndex.map(([filteredIndex, layerIndex]) => {
|
||||
const fieldsWithBrokenReferences: string[] = invalidFieldsPerLayer[filteredIndex].map(
|
||||
(columnId) => {
|
||||
const column = invalidLayers[filteredIndex].columns[
|
||||
columnId
|
||||
] as FieldBasedIndexPatternColumn;
|
||||
return column.sourceField;
|
||||
}
|
||||
),
|
||||
longMessage: i18n.translate(
|
||||
'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer',
|
||||
{
|
||||
defaultMessage: `{fieldsLength, plural, one {Field} other {Fields}} "{fields}" {fieldsLength, plural, one {has an} other {have}} invalid reference.`,
|
||||
);
|
||||
|
||||
if (originalLayersList.length === 1) {
|
||||
return {
|
||||
shortMessage: i18n.translate(
|
||||
'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer',
|
||||
{
|
||||
defaultMessage: 'Invalid {fields, plural, one {reference} other {references}}.',
|
||||
values: {
|
||||
fields: fieldsWithBrokenReferences.length,
|
||||
},
|
||||
}
|
||||
),
|
||||
longMessage: i18n.translate(
|
||||
'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer',
|
||||
{
|
||||
defaultMessage: `{fieldsLength, plural, one {Field} other {Fields}} "{fields}" {fieldsLength, plural, one {has an} other {have}} invalid reference.`,
|
||||
values: {
|
||||
fields: fieldsWithBrokenReferences.join('", "'),
|
||||
fieldsLength: fieldsWithBrokenReferences.length,
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', {
|
||||
defaultMessage:
|
||||
'Invalid {fieldsLength, plural, one {reference} other {references}} on Layer {layer}.',
|
||||
values: {
|
||||
layer: layerIndex,
|
||||
fieldsLength: fieldsWithBrokenReferences.length,
|
||||
},
|
||||
}),
|
||||
longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', {
|
||||
defaultMessage: `Layer {layer} has {fieldsLength, plural, one {an invalid} other {invalid}} {fieldsLength, plural, one {reference} other {references}} in {fieldsLength, plural, one {field} other {fields}} "{fields}".`,
|
||||
values: {
|
||||
layer: layerIndex,
|
||||
fields: fieldsWithBrokenReferences.join('", "'),
|
||||
fieldsLength: fieldsWithBrokenReferences.length,
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', {
|
||||
defaultMessage:
|
||||
'Invalid {fieldsLength, plural, one {reference} other {references}} on Layer {layer}.',
|
||||
values: {
|
||||
layer: layerIndex,
|
||||
fieldsLength: fieldsWithBrokenReferences.length,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', {
|
||||
defaultMessage: `Layer {layer} has {fieldsLength, plural, one {an invalid} other {invalid}} {fieldsLength, plural, one {reference} other {references}} in {fieldsLength, plural, one {field} other {fields}} "{fields}".`,
|
||||
values: {
|
||||
layer: layerIndex,
|
||||
fields: fieldsWithBrokenReferences.join('", "'),
|
||||
fieldsLength: fieldsWithBrokenReferences.length,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
IndexPatternColumn,
|
||||
OperationType,
|
||||
} from './operations';
|
||||
import { hasField, hasInvalidReference } from './utils';
|
||||
import { hasField, hasInvalidFields } from './utils';
|
||||
import {
|
||||
IndexPattern,
|
||||
IndexPatternPrivateState,
|
||||
|
@ -90,7 +90,7 @@ export function getDatasourceSuggestionsForField(
|
|||
indexPatternId: string,
|
||||
field: IndexPatternField
|
||||
): IndexPatternSugestion[] {
|
||||
if (hasInvalidReference(state)) return [];
|
||||
if (hasInvalidFields(state)) return [];
|
||||
const layers = Object.keys(state.layers);
|
||||
const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
|
||||
|
||||
|
@ -331,7 +331,7 @@ function createNewLayerWithMetricAggregation(
|
|||
export function getDatasourceSuggestionsFromCurrentState(
|
||||
state: IndexPatternPrivateState
|
||||
): Array<DatasourceSuggestion<IndexPatternPrivateState>> {
|
||||
if (hasInvalidReference(state)) return [];
|
||||
if (hasInvalidFields(state)) return [];
|
||||
const layers = Object.entries(state.layers || {});
|
||||
if (layers.length > 1) {
|
||||
// Return suggestions that reduce the data to each layer individually
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { DragContextState } from '../drag_drop';
|
||||
import { getFieldByNameFactory } from './pure_helpers';
|
||||
import { IndexPattern } from './types';
|
||||
import type { IndexPattern } from './types';
|
||||
|
||||
export const createMockedIndexPattern = (): IndexPattern => {
|
||||
const fields = [
|
||||
|
|
|
@ -6,12 +6,14 @@
|
|||
|
||||
const actualOperations = jest.requireActual('../operations');
|
||||
const actualHelpers = jest.requireActual('../layer_helpers');
|
||||
const actualMocks = jest.requireActual('../mocks');
|
||||
|
||||
jest.spyOn(actualOperations.operationDefinitionMap.date_histogram, 'paramEditor');
|
||||
jest.spyOn(actualOperations.operationDefinitionMap.terms, 'onOtherColumnChanged');
|
||||
jest.spyOn(actualHelpers, 'insertOrReplaceColumn');
|
||||
jest.spyOn(actualHelpers, 'insertNewColumn');
|
||||
jest.spyOn(actualHelpers, 'replaceColumn');
|
||||
jest.spyOn(actualHelpers, 'getErrorMessages');
|
||||
|
||||
export const {
|
||||
getAvailableOperationsByMetadata,
|
||||
|
@ -35,4 +37,8 @@ export const {
|
|||
updateLayerIndexPattern,
|
||||
mergeLayer,
|
||||
isColumnTransferable,
|
||||
getErrorMessages,
|
||||
isReferenced,
|
||||
} = actualHelpers;
|
||||
|
||||
export const { createMockedReferenceOperation } = actualMocks;
|
||||
|
|
|
@ -52,6 +52,8 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo
|
|||
(!newField.aggregationRestrictions || newField.aggregationRestrictions.cardinality)
|
||||
);
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
ofName(indexPattern.getFieldByName(column.sourceField)!.displayName),
|
||||
buildColumn({ field, previousColumn }) {
|
||||
return {
|
||||
label: ofName(field.displayName),
|
||||
|
|
|
@ -4,13 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Operation } from '../../../types';
|
||||
import type { Operation } from '../../../types';
|
||||
|
||||
/**
|
||||
* This is the root type of a column. If you are implementing a new
|
||||
* operation, extend your column type on `BaseIndexPatternColumn` to make
|
||||
* sure it's matching all the basic requirements.
|
||||
*/
|
||||
export interface BaseIndexPatternColumn extends Operation {
|
||||
// Private
|
||||
operationType: string;
|
||||
|
@ -18,7 +13,8 @@ export interface BaseIndexPatternColumn extends Operation {
|
|||
}
|
||||
|
||||
// Formatting can optionally be added to any column
|
||||
export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn {
|
||||
// export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn {
|
||||
export type FormattedIndexPatternColumn = BaseIndexPatternColumn & {
|
||||
params?: {
|
||||
format: {
|
||||
id: string;
|
||||
|
@ -27,8 +23,20 @@ export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn {
|
|||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn {
|
||||
sourceField: string;
|
||||
}
|
||||
|
||||
export interface ReferenceBasedIndexPatternColumn
|
||||
extends BaseIndexPatternColumn,
|
||||
FormattedIndexPatternColumn {
|
||||
references: string[];
|
||||
}
|
||||
|
||||
// Used to store the temporary invalid state
|
||||
export interface IncompleteColumn {
|
||||
operationType?: string;
|
||||
sourceField?: string;
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
|
|||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: () => countLabel,
|
||||
buildColumn({ field, previousColumn }) {
|
||||
return {
|
||||
label: countLabel,
|
||||
|
|
|
@ -188,7 +188,7 @@ describe('date_histogram', () => {
|
|||
describe('buildColumn', () => {
|
||||
it('should create column object with auto interval for primary time field', () => {
|
||||
const column = dateHistogramOperation.buildColumn({
|
||||
columns: {},
|
||||
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
|
||||
indexPattern: createMockedIndexPattern(),
|
||||
field: {
|
||||
name: 'timestamp',
|
||||
|
@ -204,7 +204,7 @@ describe('date_histogram', () => {
|
|||
|
||||
it('should create column object with auto interval for non-primary time fields', () => {
|
||||
const column = dateHistogramOperation.buildColumn({
|
||||
columns: {},
|
||||
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
|
||||
indexPattern: createMockedIndexPattern(),
|
||||
field: {
|
||||
name: 'start_date',
|
||||
|
@ -220,7 +220,7 @@ describe('date_histogram', () => {
|
|||
|
||||
it('should create column object with restrictions', () => {
|
||||
const column = dateHistogramOperation.buildColumn({
|
||||
columns: {},
|
||||
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
|
||||
indexPattern: createMockedIndexPattern(),
|
||||
field: {
|
||||
name: 'timestamp',
|
||||
|
|
|
@ -59,6 +59,8 @@ export const dateHistogramOperation: OperationDefinition<
|
|||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
indexPattern.getFieldByName(column.sourceField)!.displayName,
|
||||
buildColumn({ field }) {
|
||||
let interval = autoInterval;
|
||||
let timeZone: string | undefined;
|
||||
|
|
|
@ -75,6 +75,7 @@ export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn, 'n
|
|||
input: 'none',
|
||||
isTransferable: () => true,
|
||||
|
||||
getDefaultLabel: () => filtersLabel,
|
||||
buildColumn({ previousColumn }) {
|
||||
let params = { filters: [defaultFilter] };
|
||||
if (previousColumn?.operationType === 'terms') {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ExpressionFunctionAST } from '@kbn/interpreter/common';
|
||||
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
|
||||
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
|
||||
import { termsOperation, TermsIndexPatternColumn } from './terms';
|
||||
|
@ -24,8 +25,13 @@ import {
|
|||
import { dateHistogramOperation, DateHistogramIndexPatternColumn } from './date_histogram';
|
||||
import { countOperation, CountIndexPatternColumn } from './count';
|
||||
import { StateSetter, OperationMetadata } from '../../../types';
|
||||
import { BaseIndexPatternColumn } from './column_types';
|
||||
import { IndexPatternPrivateState, IndexPattern, IndexPatternField } from '../../types';
|
||||
import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
|
||||
import {
|
||||
IndexPatternPrivateState,
|
||||
IndexPattern,
|
||||
IndexPatternField,
|
||||
IndexPatternLayer,
|
||||
} from '../../types';
|
||||
import { DateRange } from '../../../../common';
|
||||
import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public';
|
||||
import { RangeIndexPatternColumn, rangeOperation } from './ranges';
|
||||
|
@ -50,6 +56,8 @@ export type IndexPatternColumn =
|
|||
|
||||
export type FieldBasedIndexPatternColumn = Extract<IndexPatternColumn, { sourceField: string }>;
|
||||
|
||||
export { IncompleteColumn } from './column_types';
|
||||
|
||||
// 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.
|
||||
|
@ -104,6 +112,14 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
|
|||
* Should be i18n-ified.
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* The default label is assigned by the editor
|
||||
*/
|
||||
getDefaultLabel: (
|
||||
column: C,
|
||||
indexPattern: IndexPattern,
|
||||
columns: Record<string, IndexPatternColumn>
|
||||
) => string;
|
||||
/**
|
||||
* This function is called if another column in the same layer changed or got removed.
|
||||
* Can be used to update references to other columns (e.g. for sorting).
|
||||
|
@ -118,11 +134,6 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
|
|||
* React component for operation specific settings shown in the popover editor
|
||||
*/
|
||||
paramEditor?: React.ComponentType<ParamEditorProps<C>>;
|
||||
/**
|
||||
* Function turning a column into an agg config passed to the `esaggs` function
|
||||
* together with the agg configs returned from other columns.
|
||||
*/
|
||||
toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown;
|
||||
/**
|
||||
* Returns true if the `column` can also be used on `newIndexPattern`.
|
||||
* If this function returns false, the column is removed when switching index pattern
|
||||
|
@ -138,7 +149,7 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
|
|||
}
|
||||
|
||||
interface BaseBuildColumnArgs {
|
||||
columns: Partial<Record<string, IndexPatternColumn>>;
|
||||
layer: IndexPatternLayer;
|
||||
indexPattern: IndexPattern;
|
||||
}
|
||||
|
||||
|
@ -156,7 +167,12 @@ interface FieldlessOperationDefinition<C extends BaseIndexPatternColumn> {
|
|||
* Returns the meta data of the operation if applied. Undefined
|
||||
* if the field is not applicable.
|
||||
*/
|
||||
getPossibleOperation: () => OperationMetadata | undefined;
|
||||
getPossibleOperation: () => OperationMetadata;
|
||||
/**
|
||||
* Function turning a column into an agg config passed to the `esaggs` function
|
||||
* together with the agg configs returned from other columns.
|
||||
*/
|
||||
toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown;
|
||||
}
|
||||
|
||||
interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> {
|
||||
|
@ -167,7 +183,7 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> {
|
|||
*/
|
||||
getPossibleOperationForField: (field: IndexPatternField) => OperationMetadata | undefined;
|
||||
/**
|
||||
* Builds the column object for the given parameters. Should include default p
|
||||
* Builds the column object for the given parameters.
|
||||
*/
|
||||
buildColumn: (
|
||||
arg: BaseBuildColumnArgs & {
|
||||
|
@ -191,11 +207,76 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> {
|
|||
* @param field The field that the user changed to.
|
||||
*/
|
||||
onFieldChange: (oldColumn: C, field: IndexPatternField) => C;
|
||||
/**
|
||||
* Function turning a column into an agg config passed to the `esaggs` function
|
||||
* together with the agg configs returned from other columns.
|
||||
*/
|
||||
toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown;
|
||||
}
|
||||
|
||||
export interface RequiredReference {
|
||||
// Limit the input types, usually used to prevent other references from being used
|
||||
input: Array<GenericOperationDefinition['input']>;
|
||||
// Function which is used to determine if the reference is bucketed, or if it's a number
|
||||
validateMetadata: (metadata: OperationMetadata) => boolean;
|
||||
// Do not use specificOperations unless you need to limit to only one or two exact
|
||||
// operation types. The main use case is Cumulative Sum, where we need to only take the
|
||||
// sum of Count or sum of Sum.
|
||||
specificOperations?: OperationType[];
|
||||
}
|
||||
|
||||
// Full reference uses one or more reference operations which are visible to the user
|
||||
// Partial reference is similar except that it uses the field selector
|
||||
interface FullReferenceOperationDefinition<C extends BaseIndexPatternColumn> {
|
||||
input: 'fullReference';
|
||||
/**
|
||||
* The filters provided here are used to construct the UI, transition correctly
|
||||
* between operations, and validate the configuration.
|
||||
*/
|
||||
requiredReferences: RequiredReference[];
|
||||
|
||||
/**
|
||||
* The type of UI that is shown in the editor for this function:
|
||||
* - full: List of sub-functions and fields
|
||||
* - field: List of fields, selects first operation per field
|
||||
*/
|
||||
selectionStyle: 'full' | 'field';
|
||||
|
||||
/**
|
||||
* Builds the column object for the given parameters. Should include default p
|
||||
*/
|
||||
buildColumn: (
|
||||
arg: BaseBuildColumnArgs & {
|
||||
referenceIds: string[];
|
||||
previousColumn?: IndexPatternColumn;
|
||||
}
|
||||
) => ReferenceBasedIndexPatternColumn & C;
|
||||
/**
|
||||
* Returns the meta data of the operation if applied. Undefined
|
||||
* if the field is not applicable.
|
||||
*/
|
||||
getPossibleOperation: () => OperationMetadata;
|
||||
/**
|
||||
* A chain of expression functions which will transform the table
|
||||
*/
|
||||
toExpression: (
|
||||
layer: IndexPatternLayer,
|
||||
columnId: string,
|
||||
indexPattern: IndexPattern
|
||||
) => ExpressionFunctionAST[];
|
||||
/**
|
||||
* Validate that the operation has the right preconditions in the state. For example:
|
||||
*
|
||||
* - Requires a date histogram operation somewhere before it in order
|
||||
* - Missing references
|
||||
*/
|
||||
getErrorMessage?: (layer: IndexPatternLayer, columnId: string) => string[] | undefined;
|
||||
}
|
||||
|
||||
interface OperationDefinitionMap<C extends BaseIndexPatternColumn> {
|
||||
field: FieldBasedOperationDefinition<C>;
|
||||
none: FieldlessOperationDefinition<C>;
|
||||
fullReference: FullReferenceOperationDefinition<C>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -220,7 +301,8 @@ export type OperationType = typeof internalOperationDefinitions[number]['type'];
|
|||
*/
|
||||
export type GenericOperationDefinition =
|
||||
| OperationDefinition<IndexPatternColumn, 'field'>
|
||||
| OperationDefinition<IndexPatternColumn, 'none'>;
|
||||
| OperationDefinition<IndexPatternColumn, 'none'>
|
||||
| OperationDefinition<IndexPatternColumn, 'fullReference'>;
|
||||
|
||||
/**
|
||||
* List of all available operation definitions
|
||||
|
|
|
@ -52,6 +52,8 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
(!newField.aggregationRestrictions || newField.aggregationRestrictions![type])
|
||||
);
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) =>
|
||||
ofName(indexPattern.getFieldByName(column.sourceField)!.displayName),
|
||||
buildColumn: ({ field, previousColumn }) => ({
|
||||
label: ofName(field.displayName),
|
||||
dataType: 'number',
|
||||
|
|
|
@ -122,9 +122,11 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field
|
|||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
indexPattern.getFieldByName(column.sourceField)!.displayName,
|
||||
buildColumn({ field }) {
|
||||
return {
|
||||
label: field.name,
|
||||
label: field.displayName,
|
||||
dataType: 'number', // string for Range
|
||||
operationType: 'range',
|
||||
sourceField: field.name,
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { IndexPatternColumn } from '../../../indexpattern';
|
||||
import { updateColumnParam } from '../../layer_helpers';
|
||||
import { updateColumnParam, isReferenced } from '../../layer_helpers';
|
||||
import { DataType } from '../../../../types';
|
||||
import { OperationDefinition } from '../index';
|
||||
import { FieldBasedIndexPatternColumn } from '../column_types';
|
||||
|
@ -82,13 +82,16 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
|
|||
(!column.params.otherBucket || !newIndexPattern.hasRestrictions)
|
||||
);
|
||||
},
|
||||
buildColumn({ columns, field, indexPattern }) {
|
||||
const existingMetricColumn = Object.entries(columns)
|
||||
.filter(([_columnId, column]) => column && isSortableByColumn(column))
|
||||
buildColumn({ layer, field, indexPattern }) {
|
||||
const existingMetricColumn = Object.entries(layer.columns)
|
||||
.filter(
|
||||
([columnId, column]) => column && !column.isBucketed && !isReferenced(layer, columnId)
|
||||
)
|
||||
.map(([id]) => id)[0];
|
||||
|
||||
const previousBucketsLength = Object.values(columns).filter((col) => col && col.isBucketed)
|
||||
.length;
|
||||
const previousBucketsLength = Object.values(layer.columns).filter(
|
||||
(col) => col && col.isBucketed
|
||||
).length;
|
||||
|
||||
return {
|
||||
label: ofName(field.displayName),
|
||||
|
@ -131,6 +134,8 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
|
|||
},
|
||||
};
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
ofName(indexPattern.getFieldByName(column.sourceField)!.displayName),
|
||||
onFieldChange: (oldColumn, field) => {
|
||||
const newParams = { ...oldColumn.params };
|
||||
if ('format' in newParams && field.type !== 'number') {
|
||||
|
|
|
@ -270,7 +270,7 @@ describe('terms', () => {
|
|||
name: 'test',
|
||||
displayName: 'test',
|
||||
},
|
||||
columns: {},
|
||||
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
|
||||
});
|
||||
expect(termsColumn.dataType).toEqual('boolean');
|
||||
});
|
||||
|
@ -285,7 +285,7 @@ describe('terms', () => {
|
|||
name: 'test',
|
||||
displayName: 'test',
|
||||
},
|
||||
columns: {},
|
||||
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
|
||||
});
|
||||
expect(termsColumn.params.otherBucket).toEqual(true);
|
||||
});
|
||||
|
@ -300,7 +300,7 @@ describe('terms', () => {
|
|||
name: 'test',
|
||||
displayName: 'test',
|
||||
},
|
||||
columns: {},
|
||||
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
|
||||
});
|
||||
expect(termsColumn.params.otherBucket).toEqual(false);
|
||||
});
|
||||
|
@ -308,14 +308,18 @@ describe('terms', () => {
|
|||
it('should use existing metric column as order column', () => {
|
||||
const termsColumn = termsOperation.buildColumn({
|
||||
indexPattern: createMockedIndexPattern(),
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Count',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'Records',
|
||||
operationType: 'count',
|
||||
layer: {
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Count',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'Records',
|
||||
operationType: 'count',
|
||||
},
|
||||
},
|
||||
columnOrder: [],
|
||||
indexPatternId: '',
|
||||
},
|
||||
field: {
|
||||
aggregatable: true,
|
||||
|
@ -335,7 +339,7 @@ describe('terms', () => {
|
|||
it('should use the default size when there is an existing bucket', () => {
|
||||
const termsColumn = termsOperation.buildColumn({
|
||||
indexPattern: createMockedIndexPattern(),
|
||||
columns: state.layers.first.columns,
|
||||
layer: state.layers.first,
|
||||
field: {
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
|
@ -350,7 +354,7 @@ describe('terms', () => {
|
|||
it('should use a size of 5 when there are no other buckets', () => {
|
||||
const termsColumn = termsOperation.buildColumn({
|
||||
indexPattern: createMockedIndexPattern(),
|
||||
columns: {},
|
||||
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
|
||||
field: {
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
|
|
|
@ -6,4 +6,11 @@
|
|||
|
||||
export * from './operations';
|
||||
export * from './layer_helpers';
|
||||
export { OperationType, IndexPatternColumn, FieldBasedIndexPatternColumn } from './definitions';
|
||||
export {
|
||||
OperationType,
|
||||
IndexPatternColumn,
|
||||
FieldBasedIndexPatternColumn,
|
||||
IncompleteColumn,
|
||||
} from './definitions';
|
||||
|
||||
export { createMockedReferenceOperation } from './mocks';
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import type { OperationMetadata } from '../../types';
|
||||
import {
|
||||
insertNewColumn,
|
||||
replaceColumn,
|
||||
|
@ -11,16 +12,20 @@ import {
|
|||
getColumnOrder,
|
||||
deleteColumn,
|
||||
updateLayerIndexPattern,
|
||||
getErrorMessages,
|
||||
} from './layer_helpers';
|
||||
import { operationDefinitionMap, OperationType } from '../operations';
|
||||
import { TermsIndexPatternColumn } from './definitions/terms';
|
||||
import { DateHistogramIndexPatternColumn } from './definitions/date_histogram';
|
||||
import { AvgIndexPatternColumn } from './definitions/metrics';
|
||||
import { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from '../types';
|
||||
import type { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from '../types';
|
||||
import { documentField } from '../document_field';
|
||||
import { getFieldByNameFactory } from '../pure_helpers';
|
||||
import { generateId } from '../../id_generator';
|
||||
import { createMockedReferenceOperation } from './mocks';
|
||||
|
||||
jest.mock('../operations');
|
||||
jest.mock('../../id_generator');
|
||||
|
||||
const indexPatternFields = [
|
||||
{
|
||||
|
@ -74,10 +79,22 @@ const indexPattern = {
|
|||
timeFieldName: 'timestamp',
|
||||
hasRestrictions: false,
|
||||
fields: indexPatternFields,
|
||||
getFieldByName: getFieldByNameFactory(indexPatternFields),
|
||||
getFieldByName: getFieldByNameFactory([...indexPatternFields, documentField]),
|
||||
};
|
||||
|
||||
describe('state_helpers', () => {
|
||||
beforeEach(() => {
|
||||
let count = 0;
|
||||
(generateId as jest.Mock).mockImplementation(() => `id${++count}`);
|
||||
|
||||
// @ts-expect-error we are inserting an invalid type
|
||||
operationDefinitionMap.testReference = createMockedReferenceOperation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete operationDefinitionMap.testReference;
|
||||
});
|
||||
|
||||
describe('insertNewColumn', () => {
|
||||
it('should throw for invalid operations', () => {
|
||||
expect(() => {
|
||||
|
@ -315,6 +332,110 @@ describe('state_helpers', () => {
|
|||
})
|
||||
).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2', 'col3'] }));
|
||||
});
|
||||
|
||||
describe('inserting a new reference', () => {
|
||||
it('should throw if the required references are impossible to match', () => {
|
||||
// @ts-expect-error this function is not valid
|
||||
operationDefinitionMap.testReference.requiredReferences = [
|
||||
{
|
||||
input: ['none', 'field'],
|
||||
validateMetadata: () => false,
|
||||
specificOperations: [],
|
||||
},
|
||||
];
|
||||
const layer: IndexPatternLayer = { indexPatternId: '1', columnOrder: [], columns: {} };
|
||||
expect(() => {
|
||||
insertNewColumn({
|
||||
layer,
|
||||
indexPattern,
|
||||
columnId: 'col2',
|
||||
op: 'testReference' as OperationType,
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should leave the references empty if too ambiguous', () => {
|
||||
const layer: IndexPatternLayer = { indexPatternId: '1', columnOrder: [], columns: {} };
|
||||
const result = insertNewColumn({
|
||||
layer,
|
||||
indexPattern,
|
||||
columnId: 'col2',
|
||||
op: 'testReference' as OperationType,
|
||||
});
|
||||
|
||||
expect(operationDefinitionMap.testReference.buildColumn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceIds: ['id1'],
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
columns: {
|
||||
col2: expect.objectContaining({ references: ['id1'] }),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an operation if there is exactly one possible match', () => {
|
||||
// There is only one operation with `none` as the input type
|
||||
// @ts-expect-error this function is not valid
|
||||
operationDefinitionMap.testReference.requiredReferences = [
|
||||
{
|
||||
input: ['none'],
|
||||
validateMetadata: () => true,
|
||||
},
|
||||
];
|
||||
const layer: IndexPatternLayer = { indexPatternId: '1', columnOrder: [], columns: {} };
|
||||
const result = insertNewColumn({
|
||||
layer,
|
||||
indexPattern,
|
||||
columnId: 'col1',
|
||||
// @ts-expect-error invalid type
|
||||
op: 'testReference',
|
||||
});
|
||||
expect(result.columnOrder).toEqual(['id1', 'col1']);
|
||||
expect(result.columns).toEqual(
|
||||
expect.objectContaining({
|
||||
id1: expect.objectContaining({ operationType: 'filters' }),
|
||||
col1: expect.objectContaining({ references: ['id1'] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a referenced column if the ID is being used as a reference', () => {
|
||||
const layer: IndexPatternLayer = {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1'],
|
||||
columns: {
|
||||
col1: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// @ts-expect-error only in test
|
||||
operationType: 'testReference',
|
||||
references: ['ref1'],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(
|
||||
insertNewColumn({
|
||||
layer,
|
||||
indexPattern,
|
||||
columnId: 'ref1',
|
||||
op: 'count',
|
||||
field: documentField,
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
columns: {
|
||||
col1: expect.objectContaining({ references: ['ref1'] }),
|
||||
ref1: expect.objectContaining({}),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceColumn', () => {
|
||||
|
@ -655,10 +776,301 @@ describe('state_helpers', () => {
|
|||
}),
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
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,
|
||||
label: 'Filters',
|
||||
scale: 'ordinal', // added in buildColumn
|
||||
params: {
|
||||
filters: [{ input: { query: '', language: 'kuery' }, label: '' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
layer,
|
||||
indexPattern,
|
||||
columnId: 'col2',
|
||||
op: 'count',
|
||||
field: documentField,
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
columnOrder: ['col2'],
|
||||
columns: {
|
||||
col2: expect.objectContaining(expectedCol),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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: [
|
||||
{
|
||||
// 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 }),
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
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',
|
||||
// @ts-expect-error not statically available
|
||||
op: 'secondTest',
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
columnOrder: ['col2'],
|
||||
columns: {
|
||||
col2: expect.objectContaining({ references: ['id1'] }),
|
||||
},
|
||||
incompleteColumns: {},
|
||||
})
|
||||
);
|
||||
|
||||
delete operationDefinitionMap.secondTest;
|
||||
});
|
||||
|
||||
it('should allow making a replacement on an operation that is being referenced, even if it ends up invalid', () => {
|
||||
// @ts-expect-error this function is not valid
|
||||
operationDefinitionMap.testReference.requiredReferences = [
|
||||
{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number',
|
||||
specificOperations: ['sum'],
|
||||
},
|
||||
];
|
||||
|
||||
const layer: IndexPatternLayer = {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Asdf',
|
||||
customLabel: true,
|
||||
dataType: 'number' as const,
|
||||
isBucketed: false,
|
||||
|
||||
operationType: 'sum' as const,
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
col2: {
|
||||
label: 'Test reference',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// @ts-expect-error not a valid type
|
||||
operationType: 'testReference',
|
||||
references: ['col1'],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(
|
||||
replaceColumn({
|
||||
layer,
|
||||
indexPattern,
|
||||
columnId: 'col1',
|
||||
op: 'count',
|
||||
field: documentField,
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: expect.objectContaining({
|
||||
sourceField: 'Records',
|
||||
operationType: 'count',
|
||||
}),
|
||||
col2: expect.objectContaining({ references: ['col1'] }),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteColumn', () => {
|
||||
it('should remove column', () => {
|
||||
it('should clear incomplete columns when column is already empty', () => {
|
||||
expect(
|
||||
deleteColumn({
|
||||
layer: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {},
|
||||
incompleteColumns: {
|
||||
col1: { sourceField: 'test' },
|
||||
},
|
||||
},
|
||||
columnId: 'col1',
|
||||
})
|
||||
).toEqual({
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {},
|
||||
incompleteColumns: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove column and any incomplete state', () => {
|
||||
const termsColumn: TermsIndexPatternColumn = {
|
||||
label: 'Top values of source',
|
||||
dataType: 'string',
|
||||
|
@ -682,25 +1094,33 @@ describe('state_helpers', () => {
|
|||
columns: {
|
||||
col1: termsColumn,
|
||||
col2: {
|
||||
label: 'Count',
|
||||
label: 'Count of records',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'Records',
|
||||
operationType: 'count',
|
||||
},
|
||||
},
|
||||
incompleteColumns: {
|
||||
col2: { sourceField: 'other' },
|
||||
},
|
||||
},
|
||||
columnId: 'col2',
|
||||
}).columns
|
||||
})
|
||||
).toEqual({
|
||||
col1: {
|
||||
...termsColumn,
|
||||
params: {
|
||||
...termsColumn.params,
|
||||
orderBy: { type: 'alphabetical' },
|
||||
orderDirection: 'asc',
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1'],
|
||||
columns: {
|
||||
col1: {
|
||||
...termsColumn,
|
||||
params: {
|
||||
...termsColumn.params,
|
||||
orderBy: { type: 'alphabetical' },
|
||||
orderDirection: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
incompleteColumns: {},
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -742,6 +1162,73 @@ describe('state_helpers', () => {
|
|||
col1: termsColumn,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete the column and all of its references', () => {
|
||||
const layer: IndexPatternLayer = {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Count',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
},
|
||||
col2: {
|
||||
label: 'Test reference',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// @ts-expect-error not a valid type
|
||||
operationType: 'testReference',
|
||||
references: ['col1'],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(deleteColumn({ layer, columnId: 'col2' })).toEqual(
|
||||
expect.objectContaining({ columnOrder: [], columns: {} })
|
||||
);
|
||||
});
|
||||
|
||||
it('should recursively delete references', () => {
|
||||
const layer: IndexPatternLayer = {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2', 'col3'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Count',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
},
|
||||
col2: {
|
||||
label: 'Test reference',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// @ts-expect-error not a valid type
|
||||
operationType: 'testReference',
|
||||
references: ['col1'],
|
||||
},
|
||||
col3: {
|
||||
label: 'Test reference 2',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// @ts-expect-error not a valid type
|
||||
operationType: 'testReference',
|
||||
references: ['col2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(deleteColumn({ layer, columnId: 'col3' })).toEqual(
|
||||
expect.objectContaining({ columnOrder: [], columns: {} })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateColumnParam', () => {
|
||||
|
@ -913,6 +1400,60 @@ describe('state_helpers', () => {
|
|||
})
|
||||
).toEqual(['col1', 'col3', 'col2']);
|
||||
});
|
||||
|
||||
it('should correctly sort references to other references', () => {
|
||||
expect(
|
||||
getColumnOrder({
|
||||
columnOrder: [],
|
||||
indexPatternId: '',
|
||||
columns: {
|
||||
bucket: {
|
||||
label: 'Top values of category',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
// Private
|
||||
operationType: 'terms',
|
||||
sourceField: 'category',
|
||||
params: {
|
||||
size: 5,
|
||||
orderBy: {
|
||||
type: 'alphabetical',
|
||||
},
|
||||
orderDirection: 'asc',
|
||||
},
|
||||
},
|
||||
metric: {
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// Private
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
ref2: {
|
||||
label: 'Ref2',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// @ts-expect-error only for testing
|
||||
operationType: 'testReference',
|
||||
references: ['ref1'],
|
||||
},
|
||||
ref1: {
|
||||
label: 'Ref',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// @ts-expect-error only for testing
|
||||
operationType: 'testReference',
|
||||
references: ['bucket'],
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual(['bucket', 'metric', 'ref1', 'ref2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLayerIndexPattern', () => {
|
||||
|
@ -1141,4 +1682,67 @@ describe('state_helpers', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getErrorMessages', () => {
|
||||
it('should collect errors from the operation definitions', () => {
|
||||
const mock = jest.fn().mockReturnValue(['error 1']);
|
||||
// @ts-expect-error not statically analyzed
|
||||
operationDefinitionMap.testReference.getErrorMessage = mock;
|
||||
const errors = getErrorMessages({
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {
|
||||
col1:
|
||||
// @ts-expect-error not statically analyzed
|
||||
{ operationType: 'testReference', references: [] },
|
||||
},
|
||||
});
|
||||
expect(mock).toHaveBeenCalled();
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should identify missing references', () => {
|
||||
const errors = getErrorMessages({
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {
|
||||
col1:
|
||||
// @ts-expect-error not statically analyzed yet
|
||||
{ operationType: 'testReference', references: ['ref1', 'ref2'] },
|
||||
},
|
||||
});
|
||||
expect(errors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should identify references that are no longer valid', () => {
|
||||
// There is only one operation with `none` as the input type
|
||||
// @ts-expect-error this function is not valid
|
||||
operationDefinitionMap.testReference.requiredReferences = [
|
||||
{
|
||||
input: ['none'],
|
||||
validateMetadata: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
const errors = getErrorMessages({
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {
|
||||
// @ts-expect-error incomplete operation
|
||||
ref1: {
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
operationType: 'terms',
|
||||
},
|
||||
col1: {
|
||||
label: '',
|
||||
references: ['ref1'],
|
||||
// @ts-expect-error tests only
|
||||
operationType: 'testReference',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,13 +5,15 @@
|
|||
*/
|
||||
|
||||
import _, { partition } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
operationDefinitionMap,
|
||||
operationDefinitions,
|
||||
OperationType,
|
||||
IndexPatternColumn,
|
||||
RequiredReference,
|
||||
} from './definitions';
|
||||
import {
|
||||
import type {
|
||||
IndexPattern,
|
||||
IndexPatternField,
|
||||
IndexPatternLayer,
|
||||
|
@ -19,6 +21,7 @@ import {
|
|||
} from '../types';
|
||||
import { getSortScoreByPriority } from './operations';
|
||||
import { mergeLayer } from '../state_helpers';
|
||||
import { generateId } from '../../id_generator';
|
||||
|
||||
interface ColumnChange {
|
||||
op: OperationType;
|
||||
|
@ -35,6 +38,8 @@ export function insertOrReplaceColumn(args: ColumnChange): IndexPatternLayer {
|
|||
return insertNewColumn(args);
|
||||
}
|
||||
|
||||
// Insert a column into an empty ID. The field parameter is required when constructing
|
||||
// a field-based operation, but will cause the function to fail for any other type of operation.
|
||||
export function insertNewColumn({
|
||||
op,
|
||||
layer,
|
||||
|
@ -48,24 +53,102 @@ export function insertNewColumn({
|
|||
throw new Error('No suitable operation found for given parameters');
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
columns: layer.columns,
|
||||
indexPattern,
|
||||
previousColumn: layer.columns[columnId],
|
||||
};
|
||||
if (layer.columns[columnId]) {
|
||||
throw new Error(`Can't insert a column with an ID that is already in use`);
|
||||
}
|
||||
|
||||
// TODO: Reference based operations require more setup to create the references
|
||||
const baseOptions = { indexPattern, previousColumn: layer.columns[columnId] };
|
||||
|
||||
if (operationDefinition.input === 'none') {
|
||||
const possibleOperation = operationDefinition.getPossibleOperation();
|
||||
if (!possibleOperation) {
|
||||
throw new Error('Tried to create an invalid operation');
|
||||
if (field) {
|
||||
throw new Error(`Can't create operation ${op} with the provided field ${field.name}`);
|
||||
}
|
||||
const possibleOperation = operationDefinition.getPossibleOperation();
|
||||
const isBucketed = Boolean(possibleOperation.isBucketed);
|
||||
if (isBucketed) {
|
||||
return addBucket(layer, operationDefinition.buildColumn(baseOptions), columnId);
|
||||
return addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId);
|
||||
} else {
|
||||
return addMetric(layer, operationDefinition.buildColumn(baseOptions), columnId);
|
||||
return addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId);
|
||||
}
|
||||
}
|
||||
|
||||
if (operationDefinition.input === 'fullReference') {
|
||||
if (field) {
|
||||
throw new Error(`Reference-based operations can't take a field as input when creating`);
|
||||
}
|
||||
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 })
|
||||
);
|
||||
|
||||
if (!validOperations.length) {
|
||||
throw new Error(
|
||||
`Can't create reference, ${op} has a validation function which doesn't allow any operations`
|
||||
);
|
||||
}
|
||||
|
||||
const newId = generateId();
|
||||
if (validOperations.length === 1) {
|
||||
const def = validOperations[0];
|
||||
|
||||
const validFields =
|
||||
def.input === 'field' ? indexPattern.fields.filter(def.getPossibleOperationForField) : [];
|
||||
|
||||
if (def.input === 'none') {
|
||||
tempLayer = insertNewColumn({
|
||||
layer: tempLayer,
|
||||
columnId: newId,
|
||||
op: def.type,
|
||||
indexPattern,
|
||||
});
|
||||
} else if (validFields.length === 1) {
|
||||
// Recursively update the layer for each new reference
|
||||
tempLayer = insertNewColumn({
|
||||
layer: tempLayer,
|
||||
columnId: newId,
|
||||
op: def.type,
|
||||
indexPattern,
|
||||
field: validFields[0],
|
||||
});
|
||||
} else {
|
||||
tempLayer = {
|
||||
...tempLayer,
|
||||
incompleteColumns: {
|
||||
...tempLayer.incompleteColumns,
|
||||
[newId]: { operationType: def.type },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return newId;
|
||||
});
|
||||
|
||||
const possibleOperation = operationDefinition.getPossibleOperation();
|
||||
const isBucketed = Boolean(possibleOperation.isBucketed);
|
||||
if (isBucketed) {
|
||||
return addBucket(
|
||||
tempLayer,
|
||||
operationDefinition.buildColumn({
|
||||
...baseOptions,
|
||||
layer: tempLayer,
|
||||
referenceIds,
|
||||
}),
|
||||
columnId
|
||||
);
|
||||
} else {
|
||||
return addMetric(
|
||||
tempLayer,
|
||||
operationDefinition.buildColumn({
|
||||
...baseOptions,
|
||||
layer: tempLayer,
|
||||
referenceIds,
|
||||
}),
|
||||
columnId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,9 +164,17 @@ export function insertNewColumn({
|
|||
}
|
||||
const isBucketed = Boolean(possibleOperation.isBucketed);
|
||||
if (isBucketed) {
|
||||
return addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, field }), columnId);
|
||||
return addBucket(
|
||||
layer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer, field }),
|
||||
columnId
|
||||
);
|
||||
} else {
|
||||
return addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, field }), columnId);
|
||||
return addMetric(
|
||||
layer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer, field }),
|
||||
columnId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -99,8 +190,9 @@ export function replaceColumn({
|
|||
throw new Error(`Can't replace column because there is no prior column`);
|
||||
}
|
||||
|
||||
const isNewOperation = Boolean(op) && op !== previousColumn.operationType;
|
||||
const operationDefinition = operationDefinitionMap[op || previousColumn.operationType];
|
||||
const isNewOperation = op !== previousColumn.operationType;
|
||||
const operationDefinition = operationDefinitionMap[op];
|
||||
const previousDefinition = operationDefinitionMap[previousColumn.operationType];
|
||||
|
||||
if (!operationDefinition) {
|
||||
throw new Error('No suitable operation found for given parameters');
|
||||
|
@ -113,22 +205,49 @@ export function replaceColumn({
|
|||
};
|
||||
|
||||
if (isNewOperation) {
|
||||
// TODO: Reference based operations require more setup to create the references
|
||||
let tempLayer = { ...layer };
|
||||
|
||||
if (previousDefinition.input === 'fullReference') {
|
||||
// @ts-expect-error references are not statically analyzed
|
||||
previousColumn.references.forEach((id: string) => {
|
||||
tempLayer = deleteColumn({ layer: tempLayer, columnId: id });
|
||||
});
|
||||
}
|
||||
|
||||
if (operationDefinition.input === 'fullReference') {
|
||||
const referenceIds = operationDefinition.requiredReferences.map(() => generateId());
|
||||
|
||||
const incompleteColumns = { ...(tempLayer.incompleteColumns || {}) };
|
||||
delete incompleteColumns[columnId];
|
||||
const newColumns = {
|
||||
...tempLayer.columns,
|
||||
[columnId]: operationDefinition.buildColumn({
|
||||
...baseOptions,
|
||||
layer: tempLayer,
|
||||
referenceIds,
|
||||
previousColumn,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
|
||||
columns: newColumns,
|
||||
incompleteColumns,
|
||||
};
|
||||
}
|
||||
|
||||
if (operationDefinition.input === 'none') {
|
||||
const newColumn = operationDefinition.buildColumn(baseOptions);
|
||||
|
||||
const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer });
|
||||
if (previousColumn.customLabel) {
|
||||
newColumn.customLabel = true;
|
||||
newColumn.label = previousColumn.label;
|
||||
}
|
||||
|
||||
const newColumns = { ...tempLayer.columns, [columnId]: newColumn };
|
||||
return {
|
||||
...layer,
|
||||
columns: adjustColumnReferencesForChangedColumn(
|
||||
{ ...layer.columns, [columnId]: newColumn },
|
||||
columnId
|
||||
),
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
|
||||
columns: adjustColumnReferencesForChangedColumn(newColumns, columnId),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -136,17 +255,17 @@ export function replaceColumn({
|
|||
throw new Error(`Invariant error: ${operationDefinition.type} operation requires field`);
|
||||
}
|
||||
|
||||
const newColumn = operationDefinition.buildColumn({ ...baseOptions, field });
|
||||
const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, field });
|
||||
|
||||
if (previousColumn.customLabel) {
|
||||
newColumn.customLabel = true;
|
||||
newColumn.label = previousColumn.label;
|
||||
}
|
||||
|
||||
const newColumns = { ...layer.columns, [columnId]: newColumn };
|
||||
const newColumns = { ...tempLayer.columns, [columnId]: newColumn };
|
||||
return {
|
||||
...layer,
|
||||
columnOrder: getColumnOrder({ ...layer, columns: newColumns }),
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
|
||||
columns: adjustColumnReferencesForChangedColumn(newColumns, columnId),
|
||||
};
|
||||
} else if (
|
||||
|
@ -294,23 +413,61 @@ export function deleteColumn({
|
|||
layer: IndexPatternLayer;
|
||||
columnId: string;
|
||||
}): IndexPatternLayer {
|
||||
const column = layer.columns[columnId];
|
||||
if (!column) {
|
||||
const newIncomplete = { ...(layer.incompleteColumns || {}) };
|
||||
delete newIncomplete[columnId];
|
||||
return {
|
||||
...layer,
|
||||
columnOrder: layer.columnOrder.filter((id) => id !== columnId),
|
||||
incompleteColumns: newIncomplete,
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error this fails statically because there are no references added
|
||||
const extraDeletions: string[] = 'references' in column ? column.references : [];
|
||||
|
||||
const hypotheticalColumns = { ...layer.columns };
|
||||
delete hypotheticalColumns[columnId];
|
||||
|
||||
const newLayer = {
|
||||
let newLayer = {
|
||||
...layer,
|
||||
columns: adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId),
|
||||
};
|
||||
return { ...newLayer, columnOrder: getColumnOrder(newLayer) };
|
||||
|
||||
extraDeletions.forEach((id) => {
|
||||
newLayer = deleteColumn({ layer: newLayer, columnId: id });
|
||||
});
|
||||
|
||||
const newIncomplete = { ...(newLayer.incompleteColumns || {}) };
|
||||
delete newIncomplete[columnId];
|
||||
|
||||
return { ...newLayer, columnOrder: getColumnOrder(newLayer), incompleteColumns: newIncomplete };
|
||||
}
|
||||
|
||||
export function getColumnOrder(layer: IndexPatternLayer): string[] {
|
||||
const [aggregations, metrics] = _.partition(
|
||||
const [direct, referenceBased] = _.partition(
|
||||
Object.entries(layer.columns),
|
||||
([id, col]) => col.isBucketed
|
||||
([id, col]) => operationDefinitionMap[col.operationType].input !== 'fullReference'
|
||||
);
|
||||
// If a reference has another reference as input, put it last in sort order
|
||||
referenceBased.sort(([idA, a], [idB, b]) => {
|
||||
// @ts-expect-error not statically analyzed
|
||||
if ('references' in a && a.references.includes(idB)) {
|
||||
return 1;
|
||||
}
|
||||
// @ts-expect-error not statically analyzed
|
||||
if ('references' in b && b.references.includes(idA)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const [aggregations, metrics] = _.partition(direct, ([, col]) => col.isBucketed);
|
||||
|
||||
return aggregations.map(([id]) => id).concat(metrics.map(([id]) => id));
|
||||
return aggregations
|
||||
.map(([id]) => id)
|
||||
.concat(metrics.map(([id]) => id))
|
||||
.concat(referenceBased.map(([id]) => id));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -342,3 +499,116 @@ export function updateLayerIndexPattern(
|
|||
columnOrder: newColumnOrder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all errors from the columns in the layer, for display in the workspace. This includes:
|
||||
*
|
||||
* - All columns have complete references
|
||||
* - All column references are valid
|
||||
* - All prerequisites are met
|
||||
*/
|
||||
export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined {
|
||||
const errors: string[] = [];
|
||||
|
||||
Object.entries(layer.columns).forEach(([columnId, column]) => {
|
||||
const def = operationDefinitionMap[column.operationType];
|
||||
if (def.input === 'fullReference' && def.getErrorMessage) {
|
||||
errors.push(...(def.getErrorMessage(layer, columnId) ?? []));
|
||||
}
|
||||
|
||||
if ('references' in column) {
|
||||
// @ts-expect-error references are not statically analyzed yet
|
||||
column.references.forEach((referenceId, index) => {
|
||||
if (!layer.columns[referenceId]) {
|
||||
errors.push(
|
||||
i18n.translate('xpack.lens.indexPattern.missingReferenceError', {
|
||||
defaultMessage: 'Dimension {dimensionLabel} is incomplete',
|
||||
values: {
|
||||
// @ts-expect-error references are not statically analyzed yet
|
||||
dimensionLabel: column.label,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const referenceColumn = layer.columns[referenceId]!;
|
||||
const requirements =
|
||||
// @ts-expect-error not statically analyzed
|
||||
operationDefinitionMap[column.operationType].requiredReferences[index];
|
||||
const isValid = isColumnValidAsReference({
|
||||
validation: requirements,
|
||||
column: referenceColumn,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
errors.push(
|
||||
i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', {
|
||||
defaultMessage: 'Dimension {dimensionLabel} does not have a valid configuration',
|
||||
values: {
|
||||
// @ts-expect-error references are not statically analyzed yet
|
||||
dimensionLabel: column.label,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors.length ? errors : undefined;
|
||||
}
|
||||
|
||||
export function isReferenced(layer: IndexPatternLayer, columnId: string): boolean {
|
||||
const allReferences = Object.values(layer.columns).flatMap((col) =>
|
||||
'references' in col
|
||||
? // @ts-expect-error not statically analyzed
|
||||
col.references
|
||||
: []
|
||||
);
|
||||
return allReferences.includes(columnId);
|
||||
}
|
||||
|
||||
function isColumnValidAsReference({
|
||||
column,
|
||||
validation,
|
||||
}: {
|
||||
column: IndexPatternColumn;
|
||||
validation: RequiredReference;
|
||||
}): boolean {
|
||||
if (!column) return false;
|
||||
const operationType = column.operationType;
|
||||
const operationDefinition = operationDefinitionMap[operationType];
|
||||
return (
|
||||
validation.input.includes(operationDefinition.input) &&
|
||||
(!validation.specificOperations || validation.specificOperations.includes(operationType)) &&
|
||||
validation.validateMetadata(column)
|
||||
);
|
||||
}
|
||||
|
||||
function isOperationAllowedAsReference({
|
||||
operationType,
|
||||
validation,
|
||||
field,
|
||||
}: {
|
||||
operationType: OperationType;
|
||||
validation: RequiredReference;
|
||||
field?: IndexPatternField;
|
||||
}): boolean {
|
||||
const operationDefinition = operationDefinitionMap[operationType];
|
||||
|
||||
let hasValidMetadata = true;
|
||||
if (field && operationDefinition.input === 'field') {
|
||||
const metadata = operationDefinition.getPossibleOperationForField(field);
|
||||
hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!);
|
||||
} else if (operationDefinition.input !== 'field') {
|
||||
const metadata = operationDefinition.getPossibleOperation();
|
||||
hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!);
|
||||
} else {
|
||||
// TODO: How can we validate the metadata without a specific field?
|
||||
}
|
||||
return (
|
||||
validation.input.includes(operationDefinition.input) &&
|
||||
(!validation.specificOperations || validation.specificOperations.includes(operationType)) &&
|
||||
hasValidMetadata
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* 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 type { OperationMetadata } from '../../types';
|
||||
import type { OperationType } from './definitions';
|
||||
|
||||
export const createMockedReferenceOperation = () => {
|
||||
return {
|
||||
input: 'fullReference',
|
||||
displayName: 'Reference test',
|
||||
type: 'testReference' as OperationType,
|
||||
selectionStyle: 'full',
|
||||
requiredReferences: [
|
||||
{
|
||||
// Any numeric metric that isn't also a reference
|
||||
input: ['none', 'field'],
|
||||
validateMetadata: (meta: OperationMetadata) =>
|
||||
meta.dataType === 'number' && !meta.isBucketed,
|
||||
},
|
||||
],
|
||||
buildColumn: jest.fn((args) => {
|
||||
return {
|
||||
label: 'Test reference',
|
||||
isBucketed: false,
|
||||
dataType: 'number',
|
||||
|
||||
operationType: 'testReference',
|
||||
references: args.referenceIds,
|
||||
};
|
||||
}),
|
||||
isTransferable: jest.fn(),
|
||||
toExpression: jest.fn().mockReturnValue([]),
|
||||
getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }),
|
||||
getDefaultLabel: jest.fn().mockReturnValue('Default label'),
|
||||
};
|
||||
};
|
|
@ -87,6 +87,10 @@ type OperationFieldTuple =
|
|||
| {
|
||||
type: 'none';
|
||||
operationType: OperationType;
|
||||
}
|
||||
| {
|
||||
type: 'fullReference';
|
||||
operationType: OperationType;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -162,6 +166,11 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) {
|
|||
},
|
||||
operationDefinition.getPossibleOperation()
|
||||
);
|
||||
} else if (operationDefinition.input === 'fullReference') {
|
||||
addToMap(
|
||||
{ type: 'fullReference', operationType: operationDefinition.type },
|
||||
operationDefinition.getPossibleOperation()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -7,32 +7,29 @@
|
|||
import { Ast, ExpressionFunctionAST } from '@kbn/interpreter/common';
|
||||
import { IndexPatternColumn } from './indexpattern';
|
||||
import { operationDefinitionMap } from './operations';
|
||||
import { IndexPattern, IndexPatternPrivateState } from './types';
|
||||
import { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from './types';
|
||||
import { OriginalColumn } from './rename_columns';
|
||||
import { dateHistogramOperation } from './operations/definitions';
|
||||
|
||||
function getExpressionForLayer(
|
||||
indexPattern: IndexPattern,
|
||||
columns: Record<string, IndexPatternColumn>,
|
||||
columnOrder: string[]
|
||||
): Ast | null {
|
||||
function getExpressionForLayer(layer: IndexPatternLayer, indexPattern: IndexPattern): Ast | null {
|
||||
const { columns, columnOrder } = layer;
|
||||
|
||||
if (columnOrder.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEsAggsConfig<C extends IndexPatternColumn>(column: C, columnId: string) {
|
||||
return operationDefinitionMap[column.operationType].toEsAggsConfig(
|
||||
column,
|
||||
columnId,
|
||||
indexPattern
|
||||
);
|
||||
}
|
||||
|
||||
const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const);
|
||||
|
||||
if (columnEntries.length) {
|
||||
const aggs = columnEntries.map(([colId, col]) => {
|
||||
return getEsAggsConfig(col, colId);
|
||||
const aggs: unknown[] = [];
|
||||
const expressions: ExpressionFunctionAST[] = [];
|
||||
columnEntries.forEach(([colId, col]) => {
|
||||
const def = operationDefinitionMap[col.operationType];
|
||||
if (def.input === 'fullReference') {
|
||||
expressions.push(...def.toExpression(layer, colId, indexPattern));
|
||||
} else {
|
||||
aggs.push(def.toEsAggsConfig(col, colId, indexPattern));
|
||||
}
|
||||
});
|
||||
|
||||
const idMap = columnEntries.reduce((currentIdMap, [colId, column], index) => {
|
||||
|
@ -119,6 +116,7 @@ function getExpressionForLayer(
|
|||
},
|
||||
},
|
||||
...formatterOverrides,
|
||||
...expressions,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
@ -129,9 +127,8 @@ function getExpressionForLayer(
|
|||
export function toExpression(state: IndexPatternPrivateState, layerId: string) {
|
||||
if (state.layers[layerId]) {
|
||||
return getExpressionForLayer(
|
||||
state.indexPatterns[state.layers[layerId].indexPatternId],
|
||||
state.layers[layerId].columns,
|
||||
state.layers[layerId].columnOrder
|
||||
state.layers[layerId],
|
||||
state.indexPatterns[state.layers[layerId].indexPatternId]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { IFieldType } from 'src/plugins/data/common';
|
||||
import { IndexPatternColumn } from './operations';
|
||||
import { IndexPatternColumn, IncompleteColumn } from './operations';
|
||||
import { IndexPatternAggRestrictions } from '../../../../../src/plugins/data/public';
|
||||
|
||||
export interface IndexPattern {
|
||||
|
@ -35,6 +35,8 @@ export interface IndexPatternLayer {
|
|||
columns: Record<string, IndexPatternColumn>;
|
||||
// Each layer is tied to the index pattern that created it
|
||||
indexPatternId: string;
|
||||
// Partial columns represent the temporary invalid states
|
||||
incompleteColumns?: Record<string, IncompleteColumn>;
|
||||
}
|
||||
|
||||
export interface IndexPatternPersistedState {
|
||||
|
|
|
@ -42,11 +42,11 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg
|
|||
);
|
||||
}
|
||||
|
||||
export function hasInvalidReference(state: IndexPatternPrivateState) {
|
||||
return getInvalidReferences(state).length > 0;
|
||||
export function hasInvalidFields(state: IndexPatternPrivateState) {
|
||||
return getInvalidLayers(state).length > 0;
|
||||
}
|
||||
|
||||
export function getInvalidReferences(state: IndexPatternPrivateState) {
|
||||
export function getInvalidLayers(state: IndexPatternPrivateState) {
|
||||
return Object.values(state.layers).filter((layer) => {
|
||||
return layer.columnOrder.some((columnId) => {
|
||||
const column = layer.columns[columnId];
|
||||
|
@ -62,7 +62,7 @@ export function getInvalidReferences(state: IndexPatternPrivateState) {
|
|||
});
|
||||
}
|
||||
|
||||
export function getInvalidFieldReferencesForLayer(
|
||||
export function getInvalidFieldsForLayer(
|
||||
layers: IndexPatternLayer[],
|
||||
indexPatternMap: Record<string, IndexPattern>
|
||||
) {
|
||||
|
|
Loading…
Reference in a new issue