[Lens] Implement types for reference-based operations (#83603)

* [Lens] Implement types for reference-based operations

* Update from review feedback
This commit is contained in:
Wylie Conlon 2020-11-20 13:42:12 -05:00 committed by GitHub
parent d31ee21a86
commit b50e7ba7da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1343 additions and 181 deletions

View file

@ -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();
});
});
});

View file

@ -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;

View file

@ -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>

View file

@ -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) {

View file

@ -1054,6 +1054,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
indexPatternId: '1',
columns: {},
columnOrder: [],
incompleteColumns: {},
},
},
});

View file

@ -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 => {

View file

@ -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);
});
});
});

View file

@ -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,
},
}),
};
});
];
}
},
};

View file

@ -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

View file

@ -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 = [

View file

@ -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;

View file

@ -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),

View file

@ -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;
}

View file

@ -41,6 +41,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
};
}
},
getDefaultLabel: () => countLabel,
buildColumn({ field, previousColumn }) {
return {
label: countLabel,

View file

@ -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',

View file

@ -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;

View file

@ -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') {

View file

@ -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

View file

@ -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',

View file

@ -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,

View file

@ -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') {

View file

@ -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,

View file

@ -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';

View file

@ -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);
});
});
});

View file

@ -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
);
}

View file

@ -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'),
};
};

View file

@ -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()
);
}
});

View file

@ -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]
);
}

View file

@ -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 {

View file

@ -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>
) {