mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Lens] Introduce 4 new calculation functions: counter rate, cumulative sum, differences, and moving average (#84384)
* [Lens] UI for reference-based functions * Fix tests * Add a few unit tests for reference editor * Respond to review comments * Update error handling * Update suggestion logic to work with errors and refs * Support ParamEditor in references to fix Last Value: refactoring as needed * Fix error states * Update logic for showing references in dimension editor, add tests * Fix tests Co-authored-by: Joe Reuter <johannes.reuter@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
9bc2fccb2d
commit
23fd044562
40 changed files with 2641 additions and 778 deletions
|
@ -162,7 +162,7 @@ export function WorkspacePanel({
|
|||
|
||||
const expression = useMemo(
|
||||
() => {
|
||||
if (!configurationValidationError || configurationValidationError.length === 0) {
|
||||
if (!configurationValidationError?.length) {
|
||||
try {
|
||||
return buildExpression({
|
||||
visualization: activeVisualization,
|
||||
|
@ -400,13 +400,17 @@ export const InnerVisualizationWrapper = ({
|
|||
showExtraErrors = localState.configurationValidationError
|
||||
.slice(1)
|
||||
.map(({ longMessage }) => (
|
||||
<EuiFlexItem key={longMessage} className="eui-textBreakAll">
|
||||
<EuiFlexItem
|
||||
key={longMessage}
|
||||
className="eui-textBreakAll"
|
||||
data-test-subj="configuration-failure-error"
|
||||
>
|
||||
{longMessage}
|
||||
</EuiFlexItem>
|
||||
));
|
||||
} else {
|
||||
showExtraErrors = (
|
||||
<EuiFlexItem data-test-subj="configuration-failure-more-errors">
|
||||
<EuiFlexItem>
|
||||
<EuiButtonEmpty
|
||||
onClick={() => {
|
||||
setLocalState((prevState: WorkspaceState) => ({
|
||||
|
@ -414,6 +418,7 @@ export const InnerVisualizationWrapper = ({
|
|||
expandError: !prevState.expandError,
|
||||
}));
|
||||
}}
|
||||
data-test-subj="configuration-failure-more-errors"
|
||||
>
|
||||
{i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', {
|
||||
defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`,
|
||||
|
@ -445,7 +450,7 @@ export const InnerVisualizationWrapper = ({
|
|||
</EuiTextColor>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem className="eui-textBreakAll">
|
||||
<EuiFlexItem className="eui-textBreakAll" data-test-subj="configuration-failure-error">
|
||||
{localState.configurationValidationError[0].longMessage}
|
||||
</EuiFlexItem>
|
||||
{showExtraErrors}
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
EuiListGroupItemProps,
|
||||
EuiFormLabel,
|
||||
EuiToolTip,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { IndexPatternDimensionEditorProps } from './dimension_panel';
|
||||
import { OperationSupportMatrix } from './operation_support';
|
||||
|
@ -37,6 +38,7 @@ import { BucketNestingEditor } from './bucket_nesting_editor';
|
|||
import { IndexPattern, IndexPatternLayer } from '../types';
|
||||
import { trackUiEvent } from '../../lens_ui_telemetry';
|
||||
import { FormatSelector } from './format_selector';
|
||||
import { ReferenceEditor } from './reference_editor';
|
||||
import { TimeScaling } from './time_scaling';
|
||||
|
||||
const operationPanels = getOperationDisplay();
|
||||
|
@ -156,7 +158,10 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
(selectedColumn && !hasField(selectedColumn) && definition.input === 'none'),
|
||||
disabledStatus:
|
||||
definition.getDisabledStatus &&
|
||||
definition.getDisabledStatus(state.indexPatterns[state.currentIndexPatternId]),
|
||||
definition.getDisabledStatus(
|
||||
state.indexPatterns[state.currentIndexPatternId],
|
||||
state.layers[layerId]
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -180,7 +185,15 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
}
|
||||
|
||||
let label: EuiListGroupItemProps['label'] = operationPanels[operationType].displayName;
|
||||
if (disabledStatus) {
|
||||
if (isActive && disabledStatus) {
|
||||
label = (
|
||||
<EuiToolTip content={disabledStatus} display="block" position="left">
|
||||
<EuiText color="danger" size="s">
|
||||
<strong>{operationPanels[operationType].displayName}</strong>
|
||||
</EuiText>
|
||||
</EuiToolTip>
|
||||
);
|
||||
} else if (disabledStatus) {
|
||||
label = (
|
||||
<EuiToolTip content={disabledStatus} display="block" position="left">
|
||||
<span>{operationPanels[operationType].displayName}</span>
|
||||
|
@ -202,9 +215,12 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
compatibleWithCurrentField ? '' : ' incompatible'
|
||||
}`,
|
||||
onClick() {
|
||||
if (operationDefinitionMap[operationType].input === 'none') {
|
||||
if (
|
||||
operationDefinitionMap[operationType].input === 'none' ||
|
||||
operationDefinitionMap[operationType].input === 'fullReference'
|
||||
) {
|
||||
// Clear invalid state because we are reseting to a valid column
|
||||
if (selectedColumn?.operationType === operationType) {
|
||||
// Clear invalid state because we are reseting to a valid column
|
||||
if (incompleteInfo) {
|
||||
setStateWrapper(resetIncomplete(state.layers[layerId], columnId));
|
||||
}
|
||||
|
@ -291,6 +307,35 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
</div>
|
||||
<EuiSpacer size="s" />
|
||||
<div className="lnsIndexPatternDimensionEditor__section lnsIndexPatternDimensionEditor__section--shaded">
|
||||
{!incompleteInfo &&
|
||||
selectedColumn &&
|
||||
'references' in selectedColumn &&
|
||||
selectedOperationDefinition?.input === 'fullReference' ? (
|
||||
<>
|
||||
{selectedColumn.references.map((referenceId, index) => {
|
||||
const validation = selectedOperationDefinition.requiredReferences[index];
|
||||
|
||||
return (
|
||||
<ReferenceEditor
|
||||
key={index}
|
||||
layer={state.layers[layerId]}
|
||||
columnId={referenceId}
|
||||
updateLayer={(newLayer: IndexPatternLayer) => {
|
||||
setState(mergeLayer({ state, layerId, newLayer }));
|
||||
}}
|
||||
validation={validation}
|
||||
currentIndexPattern={currentIndexPattern}
|
||||
existingFields={state.existingFields}
|
||||
selectionStyle={selectedOperationDefinition.selectionStyle}
|
||||
dateRange={dateRange}
|
||||
{...services}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!selectedColumn ||
|
||||
selectedOperationDefinition?.input === 'field' ||
|
||||
(incompleteOperation && operationDefinitionMap[incompleteOperation].input === 'field') ? (
|
||||
|
@ -325,7 +370,13 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
}
|
||||
incompleteOperation={incompleteOperation}
|
||||
onDeleteColumn={() => {
|
||||
setStateWrapper(deleteColumn({ layer: state.layers[layerId], columnId }));
|
||||
setStateWrapper(
|
||||
deleteColumn({
|
||||
layer: state.layers[layerId],
|
||||
columnId,
|
||||
indexPattern: currentIndexPattern,
|
||||
})
|
||||
);
|
||||
}}
|
||||
onChoose={(choice) => {
|
||||
setStateWrapper(
|
||||
|
@ -342,15 +393,6 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
</EuiFormRow>
|
||||
) : null}
|
||||
|
||||
{!currentFieldIsInvalid && !incompleteInfo && selectedColumn && (
|
||||
<TimeScaling
|
||||
selectedColumn={selectedColumn}
|
||||
columnId={columnId}
|
||||
layer={state.layers[layerId]}
|
||||
updateLayer={setStateWrapper}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!currentFieldIsInvalid && !incompleteInfo && selectedColumn && ParamEditor && (
|
||||
<>
|
||||
<ParamEditor
|
||||
|
@ -364,6 +406,15 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!currentFieldIsInvalid && !incompleteInfo && selectedColumn && (
|
||||
<TimeScaling
|
||||
selectedColumn={selectedColumn}
|
||||
columnId={columnId}
|
||||
layer={state.layers[layerId]}
|
||||
updateLayer={setStateWrapper}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
@ -432,11 +483,11 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
}
|
||||
function getErrorMessage(
|
||||
selectedColumn: IndexPatternColumn | undefined,
|
||||
incompatibleSelectedOperationType: boolean,
|
||||
incompleteOperation: boolean,
|
||||
input: 'none' | 'field' | 'fullReference' | undefined,
|
||||
fieldInvalid: boolean
|
||||
) {
|
||||
if (selectedColumn && incompatibleSelectedOperationType) {
|
||||
if (selectedColumn && incompleteOperation) {
|
||||
if (input === 'field') {
|
||||
return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
|
||||
defaultMessage: 'To use this function, select a different field.',
|
||||
|
|
|
@ -854,6 +854,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: '',
|
||||
customLabel: true,
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'ts',
|
||||
params: {
|
||||
|
@ -872,6 +873,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
columnId: 'col2',
|
||||
};
|
||||
}
|
||||
|
||||
it('should not show custom options if time scaling is not available', () => {
|
||||
wrapper = mount(
|
||||
<IndexPatternDimensionEditorComponent
|
||||
|
@ -1149,15 +1151,15 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
layers: {
|
||||
first: {
|
||||
...state.layers.first,
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
...state.layers.first.columns,
|
||||
col2: expect.objectContaining({
|
||||
sourceField: 'bytes',
|
||||
operationType: 'avg',
|
||||
// Other parts of this don't matter for this test
|
||||
sourceField: 'bytes',
|
||||
}),
|
||||
},
|
||||
columnOrder: ['col1', 'col2'],
|
||||
incompleteColumns: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1237,7 +1239,9 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
it('should indicate compatible fields when selecting the operation first', () => {
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} columnId={'col2'} />);
|
||||
|
||||
wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click');
|
||||
act(() => {
|
||||
wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click');
|
||||
});
|
||||
|
||||
const options = wrapper
|
||||
.find(EuiComboBox)
|
||||
|
@ -1317,10 +1321,14 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
expect(items.map(({ label }: { label: React.ReactNode }) => label)).toEqual([
|
||||
'Average',
|
||||
'Count',
|
||||
'Counter rate',
|
||||
'Cumulative sum',
|
||||
'Differences',
|
||||
'Last value',
|
||||
'Maximum',
|
||||
'Median',
|
||||
'Minimum',
|
||||
'Moving average',
|
||||
'Sum',
|
||||
'Unique count',
|
||||
]);
|
||||
|
@ -1536,4 +1544,101 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide the top level field selector when switching from non-reference to reference', () => {
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} />);
|
||||
|
||||
expect(wrapper.find('ReferenceEditor')).toHaveLength(0);
|
||||
|
||||
wrapper
|
||||
.find('button[data-test-subj="lns-indexPatternDimension-derivative incompatible"]')
|
||||
.simulate('click');
|
||||
|
||||
expect(wrapper.find('ReferenceEditor')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should hide the reference editors when switching from reference to non-reference', () => {
|
||||
const stateWithReferences: IndexPatternPrivateState = getStateWithColumns({
|
||||
col1: {
|
||||
label: 'Differences of (incomplete)',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'derivative',
|
||||
references: ['col2'],
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
|
||||
wrapper = mount(
|
||||
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithReferences} />
|
||||
);
|
||||
|
||||
expect(wrapper.find('ReferenceEditor')).toHaveLength(1);
|
||||
|
||||
wrapper
|
||||
.find('button[data-test-subj="lns-indexPatternDimension-avg incompatible"]')
|
||||
.simulate('click');
|
||||
|
||||
expect(wrapper.find('ReferenceEditor')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should show a warning when the current dimension is no longer configurable', () => {
|
||||
const stateWithInvalidCol: IndexPatternPrivateState = getStateWithColumns({
|
||||
col1: {
|
||||
label: 'Invalid derivative',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'derivative',
|
||||
references: ['ref1'],
|
||||
},
|
||||
});
|
||||
|
||||
wrapper = mount(
|
||||
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithInvalidCol} />
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="lns-indexPatternDimension-derivative incompatible"]')
|
||||
.find('EuiText[color="danger"]')
|
||||
.first()
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should remove options to select references when there are no time fields', () => {
|
||||
const stateWithoutTime: IndexPatternPrivateState = {
|
||||
...getStateWithColumns({
|
||||
col1: {
|
||||
label: 'Avg',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
}),
|
||||
indexPatterns: {
|
||||
1: {
|
||||
id: '1',
|
||||
title: 'my-fake-index-pattern',
|
||||
hasRestrictions: false,
|
||||
fields,
|
||||
getFieldByName: getFieldByNameFactory([
|
||||
{
|
||||
name: 'bytes',
|
||||
displayName: 'bytes',
|
||||
type: 'number',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
wrapper = mount(
|
||||
<IndexPatternDimensionEditorComponent {...defaultProps} state={stateWithoutTime} />
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="lns-indexPatternDimension-derivative"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -41,6 +41,7 @@ export interface FieldSelectProps extends EuiComboBoxProps<{}> {
|
|||
onDeleteColumn: () => void;
|
||||
existingFields: IndexPatternPrivateState['existingFields'];
|
||||
fieldIsInvalid: boolean;
|
||||
markAllFieldsCompatible?: boolean;
|
||||
}
|
||||
|
||||
export function FieldSelect({
|
||||
|
@ -53,6 +54,7 @@ export function FieldSelect({
|
|||
onDeleteColumn,
|
||||
existingFields,
|
||||
fieldIsInvalid,
|
||||
markAllFieldsCompatible,
|
||||
...rest
|
||||
}: FieldSelectProps) {
|
||||
const { operationByField } = operationSupportMatrix;
|
||||
|
@ -93,7 +95,7 @@ export function FieldSelect({
|
|||
: operationByField[field]!.values().next().value,
|
||||
},
|
||||
exists: containsData(field),
|
||||
compatible: isCompatibleWithCurrentOperation(field),
|
||||
compatible: markAllFieldsCompatible || isCompatibleWithCurrentOperation(field),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
|
@ -163,6 +165,7 @@ export function FieldSelect({
|
|||
currentIndexPattern,
|
||||
operationByField,
|
||||
existingFields,
|
||||
markAllFieldsCompatible,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -49,7 +49,7 @@ export const getOperationSupportMatrix = (props: Props): OperationSupportMatrix
|
|||
supportedFieldsByOperation[operation.operationType] = new Set();
|
||||
}
|
||||
supportedFieldsByOperation[operation.operationType]?.add(operation.field);
|
||||
} else if (operation.type === 'none') {
|
||||
} else {
|
||||
supportedOperationsWithoutField.add(operation.operationType);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,436 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { ReactWrapper, ShallowWrapper } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { EuiComboBox } from '@elastic/eui';
|
||||
import { mountWithIntl as mount } from '@kbn/test/jest';
|
||||
import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
|
||||
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
|
||||
import type { DataPublicPluginStart } from 'src/plugins/data/public';
|
||||
import { OperationMetadata } from '../../types';
|
||||
import { createMockedIndexPattern } from '../mocks';
|
||||
import { ReferenceEditor, ReferenceEditorProps } from './reference_editor';
|
||||
import { insertOrReplaceColumn } from '../operations';
|
||||
import { FieldSelect } from './field_select';
|
||||
|
||||
jest.mock('../operations');
|
||||
|
||||
describe('reference editor', () => {
|
||||
let wrapper: ReactWrapper | ShallowWrapper;
|
||||
let updateLayer: jest.Mock<ReferenceEditorProps['updateLayer']>;
|
||||
|
||||
function getDefaultArgs() {
|
||||
return {
|
||||
layer: {
|
||||
indexPatternId: '1',
|
||||
columns: {},
|
||||
columnOrder: [],
|
||||
},
|
||||
columnId: 'ref',
|
||||
updateLayer,
|
||||
selectionStyle: 'full' as const,
|
||||
currentIndexPattern: createMockedIndexPattern(),
|
||||
existingFields: {
|
||||
'my-fake-index-pattern': {
|
||||
timestamp: true,
|
||||
bytes: true,
|
||||
memory: true,
|
||||
source: true,
|
||||
},
|
||||
},
|
||||
dateRange: { fromDate: 'now-1d', toDate: 'now' },
|
||||
storage: {} as IStorageWrapper,
|
||||
uiSettings: {} as IUiSettingsClient,
|
||||
savedObjectsClient: {} as SavedObjectsClientContract,
|
||||
http: {} as HttpSetup,
|
||||
data: {} as DataPublicPluginStart,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateLayer = jest.fn().mockImplementation((newLayer) => {
|
||||
if (wrapper instanceof ReactWrapper) {
|
||||
wrapper.setProps({ layer: newLayer });
|
||||
}
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('should indicate that all functions and available fields are compatible in the empty state', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const functions = wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="indexPattern-reference-function"]')
|
||||
.prop('options');
|
||||
|
||||
expect(functions).not.toContainEqual(
|
||||
expect.objectContaining({ 'data-test-subj': expect.stringContaining('Incompatible') })
|
||||
);
|
||||
|
||||
const fields = wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="indexPattern-dimension-field"]')
|
||||
.prop('options');
|
||||
|
||||
expect(fields![0].options).not.toContainEqual(
|
||||
expect.objectContaining({ 'data-test-subj': expect.stringContaining('Incompatible') })
|
||||
);
|
||||
expect(fields![1].options).not.toContainEqual(
|
||||
expect.objectContaining({ 'data-test-subj': expect.stringContaining('Incompatible') })
|
||||
);
|
||||
});
|
||||
|
||||
it('should indicate functions and fields that are incompatible with the current', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
layer={{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['ref'],
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Top values of dest',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
operationType: 'terms',
|
||||
sourceField: 'dest',
|
||||
params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'desc' },
|
||||
},
|
||||
},
|
||||
}}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => meta.isBucketed,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const functions = wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="indexPattern-reference-function"]')
|
||||
.prop('options');
|
||||
expect(functions.find(({ label }) => label === 'Date histogram')!['data-test-subj']).toContain(
|
||||
'incompatible'
|
||||
);
|
||||
|
||||
const fields = wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="indexPattern-dimension-field"]')
|
||||
.prop('options');
|
||||
expect(
|
||||
fields![0].options!.find(({ label }) => label === 'timestampLabel')!['data-test-subj']
|
||||
).toContain('Incompatible');
|
||||
});
|
||||
|
||||
it('should not update when selecting the same operation', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
layer={{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['ref'],
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
}}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const comboBox = wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="indexPattern-reference-function"]');
|
||||
const option = comboBox.prop('options')!.find(({ label }) => label === 'Average')!;
|
||||
|
||||
act(() => {
|
||||
comboBox.prop('onChange')!([option]);
|
||||
});
|
||||
expect(insertOrReplaceColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep the field when replacing an existing reference with a compatible function', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
layer={{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['ref'],
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
}}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const comboBox = wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="indexPattern-reference-function"]');
|
||||
const option = comboBox.prop('options')!.find(({ label }) => label === 'Maximum')!;
|
||||
|
||||
act(() => {
|
||||
comboBox.prop('onChange')!([option]);
|
||||
});
|
||||
|
||||
expect(insertOrReplaceColumn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
op: 'max',
|
||||
field: expect.objectContaining({ name: 'bytes' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should transition to another function with incompatible field', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
layer={{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['ref'],
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
}}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const comboBox = wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="indexPattern-reference-function"]');
|
||||
const option = comboBox.prop('options')!.find(({ label }) => label === 'Date histogram')!;
|
||||
|
||||
act(() => {
|
||||
comboBox.prop('onChange')!([option]);
|
||||
});
|
||||
|
||||
expect(insertOrReplaceColumn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
op: 'date_histogram',
|
||||
field: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should hide the function selector when using a field-only selection style', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
selectionStyle={'field' as const}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const comboBox = wrapper
|
||||
.find(EuiComboBox)
|
||||
.filter('[data-test-subj="indexPattern-reference-function"]');
|
||||
expect(comboBox).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should pass the incomplete operation info to FieldSelect', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
layer={{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['ref'],
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
incompleteColumns: {
|
||||
ref: { operationType: 'max' },
|
||||
},
|
||||
}}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const fieldSelect = wrapper.find(FieldSelect);
|
||||
expect(fieldSelect.prop('fieldIsInvalid')).toEqual(true);
|
||||
expect(fieldSelect.prop('selectedField')).toEqual('bytes');
|
||||
expect(fieldSelect.prop('selectedOperationType')).toEqual('avg');
|
||||
expect(fieldSelect.prop('incompleteOperation')).toEqual('max');
|
||||
expect(fieldSelect.prop('markAllFieldsCompatible')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should pass the incomplete field info to FieldSelect', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
layer={{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['ref'],
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
incompleteColumns: {
|
||||
ref: { sourceField: 'timestamp' },
|
||||
},
|
||||
}}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const fieldSelect = wrapper.find(FieldSelect);
|
||||
expect(fieldSelect.prop('fieldIsInvalid')).toEqual(false);
|
||||
expect(fieldSelect.prop('selectedField')).toEqual('timestamp');
|
||||
expect(fieldSelect.prop('selectedOperationType')).toEqual('avg');
|
||||
expect(fieldSelect.prop('incompleteOperation')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show the FieldSelect as invalid in the empty state for field-only forms', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
selectionStyle="field"
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const fieldSelect = wrapper.find(FieldSelect);
|
||||
expect(fieldSelect.prop('fieldIsInvalid')).toEqual(true);
|
||||
expect(fieldSelect.prop('selectedField')).toBeUndefined();
|
||||
expect(fieldSelect.prop('selectedOperationType')).toBeUndefined();
|
||||
expect(fieldSelect.prop('incompleteOperation')).toBeUndefined();
|
||||
expect(fieldSelect.prop('markAllFieldsCompatible')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should show the ParamEditor for functions that offer one', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
layer={{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['ref'],
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Last value of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'last_value',
|
||||
sourceField: 'bytes',
|
||||
params: {
|
||||
sortField: 'timestamp',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="lns-indexPattern-lastValue-sortField"]').exists()).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should hide the ParamEditor for incomplete functions', () => {
|
||||
wrapper = mount(
|
||||
<ReferenceEditor
|
||||
{...getDefaultArgs()}
|
||||
layer={{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['ref'],
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Last value of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'last_value',
|
||||
sourceField: 'bytes',
|
||||
params: {
|
||||
sortField: 'timestamp',
|
||||
},
|
||||
},
|
||||
},
|
||||
incompleteColumns: {
|
||||
ref: { operationType: 'max' },
|
||||
},
|
||||
}}
|
||||
validation={{
|
||||
input: ['field'],
|
||||
validateMetadata: (meta: OperationMetadata) => true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="lns-indexPattern-lastValue-sortField"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,306 @@
|
|||
/*
|
||||
* 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 './dimension_editor.scss';
|
||||
import _ from 'lodash';
|
||||
import React, { useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiFormRow, EuiSpacer, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
|
||||
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
|
||||
import type { DataPublicPluginStart } from 'src/plugins/data/public';
|
||||
import type { DateRange } from '../../../common';
|
||||
import type { OperationSupportMatrix } from './operation_support';
|
||||
import type { OperationType } from '../indexpattern';
|
||||
import {
|
||||
operationDefinitionMap,
|
||||
getOperationDisplay,
|
||||
insertOrReplaceColumn,
|
||||
deleteColumn,
|
||||
isOperationAllowedAsReference,
|
||||
FieldBasedIndexPatternColumn,
|
||||
RequiredReference,
|
||||
} from '../operations';
|
||||
import { FieldSelect } from './field_select';
|
||||
import { hasField } from '../utils';
|
||||
import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from '../types';
|
||||
import { trackUiEvent } from '../../lens_ui_telemetry';
|
||||
|
||||
const operationPanels = getOperationDisplay();
|
||||
|
||||
export interface ReferenceEditorProps {
|
||||
layer: IndexPatternLayer;
|
||||
selectionStyle: 'full' | 'field';
|
||||
validation: RequiredReference;
|
||||
columnId: string;
|
||||
updateLayer: (newLayer: IndexPatternLayer) => void;
|
||||
currentIndexPattern: IndexPattern;
|
||||
existingFields: IndexPatternPrivateState['existingFields'];
|
||||
dateRange: DateRange;
|
||||
|
||||
// Services
|
||||
uiSettings: IUiSettingsClient;
|
||||
storage: IStorageWrapper;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
http: HttpSetup;
|
||||
data: DataPublicPluginStart;
|
||||
}
|
||||
|
||||
export function ReferenceEditor(props: ReferenceEditorProps) {
|
||||
const {
|
||||
layer,
|
||||
columnId,
|
||||
updateLayer,
|
||||
currentIndexPattern,
|
||||
existingFields,
|
||||
validation,
|
||||
selectionStyle,
|
||||
dateRange,
|
||||
...services
|
||||
} = props;
|
||||
|
||||
const column = layer.columns[columnId];
|
||||
const selectedOperationDefinition = column && operationDefinitionMap[column.operationType];
|
||||
|
||||
const ParamEditor = selectedOperationDefinition?.paramEditor;
|
||||
|
||||
const incompleteInfo = layer.incompleteColumns ? layer.incompleteColumns[columnId] : undefined;
|
||||
const incompleteOperation = incompleteInfo?.operationType;
|
||||
const incompleteField = incompleteInfo?.sourceField ?? null;
|
||||
|
||||
// Basically the operation support matrix, but different validation
|
||||
const operationSupportMatrix: OperationSupportMatrix & {
|
||||
operationTypes: Set<OperationType>;
|
||||
} = useMemo(() => {
|
||||
const operationTypes: Set<OperationType> = new Set();
|
||||
const operationWithoutField: Set<OperationType> = new Set();
|
||||
const operationByField: Partial<Record<string, Set<OperationType>>> = {};
|
||||
const fieldByOperation: Partial<Record<OperationType, Set<string>>> = {};
|
||||
Object.values(operationDefinitionMap)
|
||||
.sort((op1, op2) => {
|
||||
return op1.displayName.localeCompare(op2.displayName);
|
||||
})
|
||||
.forEach((op) => {
|
||||
if (op.input === 'field') {
|
||||
const allFields = currentIndexPattern.fields.filter((field) =>
|
||||
isOperationAllowedAsReference({
|
||||
operationType: op.type,
|
||||
validation,
|
||||
field,
|
||||
indexPattern: currentIndexPattern,
|
||||
})
|
||||
);
|
||||
if (allFields.length) {
|
||||
operationTypes.add(op.type);
|
||||
fieldByOperation[op.type] = new Set(allFields.map(({ name }) => name));
|
||||
allFields.forEach((field) => {
|
||||
if (!operationByField[field.name]) {
|
||||
operationByField[field.name] = new Set();
|
||||
}
|
||||
operationByField[field.name]?.add(op.type);
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
isOperationAllowedAsReference({
|
||||
operationType: op.type,
|
||||
validation,
|
||||
indexPattern: currentIndexPattern,
|
||||
})
|
||||
) {
|
||||
operationTypes.add(op.type);
|
||||
operationWithoutField.add(op.type);
|
||||
}
|
||||
});
|
||||
return {
|
||||
operationTypes,
|
||||
operationWithoutField,
|
||||
operationByField,
|
||||
fieldByOperation,
|
||||
};
|
||||
}, [currentIndexPattern, validation]);
|
||||
|
||||
const functionOptions: Array<EuiComboBoxOptionOption<OperationType>> = Array.from(
|
||||
operationSupportMatrix.operationTypes
|
||||
).map((operationType) => {
|
||||
const def = operationDefinitionMap[operationType];
|
||||
const label = operationPanels[operationType].displayName;
|
||||
const isCompatible =
|
||||
!column ||
|
||||
(column &&
|
||||
hasField(column) &&
|
||||
def.input === 'field' &&
|
||||
operationSupportMatrix.fieldByOperation[operationType]?.has(column.sourceField)) ||
|
||||
(column && !hasField(column) && def.input !== 'field');
|
||||
|
||||
return {
|
||||
label,
|
||||
value: operationType,
|
||||
className: 'lnsIndexPatternDimensionEditor__operation',
|
||||
'data-test-subj': `lns-indexPatternDimension-${operationType}${
|
||||
isCompatible ? '' : ' incompatible'
|
||||
}`,
|
||||
};
|
||||
});
|
||||
|
||||
function onChooseFunction(operationType: OperationType) {
|
||||
if (column?.operationType === operationType) {
|
||||
return;
|
||||
}
|
||||
const possibleFieldNames = operationSupportMatrix.fieldByOperation[operationType];
|
||||
if (column && 'sourceField' in column && possibleFieldNames?.has(column.sourceField)) {
|
||||
// Reuse the current field if possible
|
||||
updateLayer(
|
||||
insertOrReplaceColumn({
|
||||
layer,
|
||||
columnId,
|
||||
op: operationType,
|
||||
indexPattern: currentIndexPattern,
|
||||
field: currentIndexPattern.getFieldByName(column.sourceField),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// If reusing the field is impossible, we generally can't choose for the user.
|
||||
// The one exception is if the field is the only possible field, like Count of Records.
|
||||
const possibleField =
|
||||
possibleFieldNames?.size === 1
|
||||
? currentIndexPattern.getFieldByName(possibleFieldNames.values().next().value)
|
||||
: undefined;
|
||||
|
||||
updateLayer(
|
||||
insertOrReplaceColumn({
|
||||
layer,
|
||||
columnId,
|
||||
op: operationType,
|
||||
indexPattern: currentIndexPattern,
|
||||
field: possibleField,
|
||||
})
|
||||
);
|
||||
}
|
||||
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOption = incompleteInfo?.operationType
|
||||
? [functionOptions.find(({ value }) => value === incompleteInfo.operationType)!]
|
||||
: column
|
||||
? [functionOptions.find(({ value }) => value === column.operationType)!]
|
||||
: [];
|
||||
|
||||
// If the operationType is incomplete, the user needs to select a field- so
|
||||
// the function is marked as valid.
|
||||
const showOperationInvalid = !column && !Boolean(incompleteInfo?.operationType);
|
||||
// The field is invalid if the operation has been updated without a field,
|
||||
// or if we are in a field-only mode but empty state
|
||||
const showFieldInvalid =
|
||||
Boolean(incompleteInfo?.operationType) || (selectionStyle === 'field' && !column);
|
||||
|
||||
return (
|
||||
<div id={columnId}>
|
||||
<div>
|
||||
{selectionStyle !== 'field' ? (
|
||||
<>
|
||||
<EuiFormRow
|
||||
data-test-subj="indexPattern-subFunction-selection-row"
|
||||
label={i18n.translate('xpack.lens.indexPattern.chooseSubFunction', {
|
||||
defaultMessage: 'Choose a sub-function',
|
||||
})}
|
||||
fullWidth
|
||||
isInvalid={showOperationInvalid}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
compressed
|
||||
isClearable={false}
|
||||
data-test-subj="indexPattern-reference-function"
|
||||
placeholder={i18n.translate(
|
||||
'xpack.lens.indexPattern.referenceFunctionPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Sub-function',
|
||||
}
|
||||
)}
|
||||
options={functionOptions}
|
||||
isInvalid={showOperationInvalid}
|
||||
selectedOptions={selectedOption}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
onChange={(choices) => {
|
||||
if (choices.length === 0) {
|
||||
updateLayer(
|
||||
deleteColumn({ layer, columnId, indexPattern: currentIndexPattern })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
trackUiEvent('indexpattern_dimension_field_changed');
|
||||
|
||||
onChooseFunction(choices[0].value!);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!column || selectedOperationDefinition.input === 'field' ? (
|
||||
<EuiFormRow
|
||||
data-test-subj="indexPattern-reference-field-selection-row"
|
||||
label={i18n.translate('xpack.lens.indexPattern.chooseField', {
|
||||
defaultMessage: 'Select a field',
|
||||
})}
|
||||
fullWidth
|
||||
isInvalid={showFieldInvalid}
|
||||
>
|
||||
<FieldSelect
|
||||
fieldIsInvalid={showFieldInvalid}
|
||||
currentIndexPattern={currentIndexPattern}
|
||||
existingFields={existingFields}
|
||||
operationSupportMatrix={operationSupportMatrix}
|
||||
selectedOperationType={
|
||||
// Allows operation to be selected before creating a valid column
|
||||
column ? column.operationType : incompleteOperation
|
||||
}
|
||||
selectedField={
|
||||
// Allows field to be selected
|
||||
incompleteField
|
||||
? incompleteField
|
||||
: (column as FieldBasedIndexPatternColumn)?.sourceField
|
||||
}
|
||||
incompleteOperation={incompleteOperation}
|
||||
markAllFieldsCompatible={selectionStyle === 'field'}
|
||||
onDeleteColumn={() => {
|
||||
updateLayer(deleteColumn({ layer, columnId, indexPattern: currentIndexPattern }));
|
||||
}}
|
||||
onChoose={(choice) => {
|
||||
updateLayer(
|
||||
insertOrReplaceColumn({
|
||||
layer,
|
||||
columnId,
|
||||
indexPattern: currentIndexPattern,
|
||||
op: choice.operationType,
|
||||
field: currentIndexPattern.getFieldByName(choice.field),
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
) : null}
|
||||
|
||||
{column && !incompleteInfo && ParamEditor && (
|
||||
<>
|
||||
<ParamEditor
|
||||
updateLayer={updateLayer}
|
||||
currentColumn={column}
|
||||
layer={layer}
|
||||
columnId={columnId}
|
||||
indexPattern={currentIndexPattern}
|
||||
dateRange={dateRange}
|
||||
{...services}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -858,183 +858,47 @@ describe('IndexPattern Data Source', () => {
|
|||
it('should return null for non-existant columns', () => {
|
||||
expect(publicAPI.getOperationForColumnId('col2')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for referenced columns', () => {
|
||||
publicAPI = indexPatternDatasource.getPublicAPI({
|
||||
state: {
|
||||
...enrichBaseState(baseState),
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Sum',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
operationType: 'sum',
|
||||
sourceField: 'test',
|
||||
params: {},
|
||||
} as IndexPatternColumn,
|
||||
col2: {
|
||||
label: 'Cumulative sum',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['col1'],
|
||||
params: {},
|
||||
} as IndexPatternColumn,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
layerId: 'first',
|
||||
});
|
||||
expect(publicAPI.getOperationForColumnId('col1')).toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getErrorMessages', () => {
|
||||
it('should detect a missing reference in a layer', () => {
|
||||
const state = {
|
||||
indexPatternRefs: [],
|
||||
existingFields: {},
|
||||
isFirstExistenceFetch: false,
|
||||
indexPatterns: expectedIndexPatterns,
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1'],
|
||||
columns: {
|
||||
col1: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'count', // <= invalid
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
currentIndexPatternId: '1',
|
||||
};
|
||||
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages![0]).toEqual({
|
||||
shortMessage: 'Invalid reference.',
|
||||
longMessage: '"Foo" has an invalid reference.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect and batch missing references in a layer', () => {
|
||||
const state = {
|
||||
indexPatternRefs: [],
|
||||
existingFields: {},
|
||||
isFirstExistenceFetch: false,
|
||||
indexPatterns: expectedIndexPatterns,
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'count', // <= invalid
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
col2: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo2',
|
||||
operationType: 'count', // <= invalid
|
||||
sourceField: 'memory',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
currentIndexPatternId: '1',
|
||||
};
|
||||
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages![0]).toEqual({
|
||||
shortMessage: 'Invalid references.',
|
||||
longMessage: '"Foo", "Foo2" have invalid reference.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect and batch missing references in multiple layers', () => {
|
||||
const state = {
|
||||
indexPatternRefs: [],
|
||||
existingFields: {},
|
||||
isFirstExistenceFetch: false,
|
||||
indexPatterns: expectedIndexPatterns,
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'count', // <= invalid
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
col2: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo2',
|
||||
operationType: 'count', // <= invalid
|
||||
sourceField: 'memory',
|
||||
},
|
||||
},
|
||||
},
|
||||
second: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1'],
|
||||
columns: {
|
||||
col1: {
|
||||
dataType: 'string',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'count', // <= invalid
|
||||
sourceField: 'source',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
currentIndexPatternId: '1',
|
||||
};
|
||||
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
shortMessage: 'Invalid references on Layer 1.',
|
||||
longMessage: 'Layer 1 has invalid references in "Foo", "Foo2".',
|
||||
},
|
||||
{
|
||||
shortMessage: 'Invalid reference on Layer 2.',
|
||||
longMessage: 'Layer 2 has an invalid reference in "Foo".',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return no errors if all references are satified', () => {
|
||||
const state = {
|
||||
indexPatternRefs: [],
|
||||
existingFields: {},
|
||||
isFirstExistenceFetch: false,
|
||||
indexPatterns: expectedIndexPatterns,
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1'],
|
||||
columns: {
|
||||
col1: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
currentIndexPatternId: '1',
|
||||
};
|
||||
expect(
|
||||
indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return no errors with layers with no columns', () => {
|
||||
const state: IndexPatternPrivateState = {
|
||||
indexPatternRefs: [],
|
||||
existingFields: {},
|
||||
isFirstExistenceFetch: false,
|
||||
indexPatterns: expectedIndexPatterns,
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {},
|
||||
},
|
||||
},
|
||||
currentIndexPatternId: '1',
|
||||
};
|
||||
expect(indexPatternDatasource.getErrorMessages(state)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should bubble up invalid configuration from operations', () => {
|
||||
it('should use the results of getErrorMessages directly when single layer', () => {
|
||||
(getErrorMessages as jest.Mock).mockClear();
|
||||
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
|
||||
const state: IndexPatternPrivateState = {
|
||||
|
@ -1052,11 +916,40 @@ describe('IndexPattern Data Source', () => {
|
|||
currentIndexPatternId: '1',
|
||||
};
|
||||
expect(indexPatternDatasource.getErrorMessages(state)).toEqual([
|
||||
{ shortMessage: 'error 1', longMessage: '' },
|
||||
{ shortMessage: 'error 2', longMessage: '' },
|
||||
{ longMessage: 'error 1', shortMessage: '' },
|
||||
{ longMessage: 'error 2', shortMessage: '' },
|
||||
]);
|
||||
expect(getErrorMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should prepend each error with its layer number on multi-layer chart', () => {
|
||||
(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: {},
|
||||
},
|
||||
second: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {},
|
||||
},
|
||||
},
|
||||
currentIndexPatternId: '1',
|
||||
};
|
||||
expect(indexPatternDatasource.getErrorMessages(state)).toEqual([
|
||||
{ longMessage: 'Layer 1 error: error 1', shortMessage: '' },
|
||||
{ longMessage: 'Layer 1 error: error 2', shortMessage: '' },
|
||||
]);
|
||||
expect(getErrorMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateStateOnCloseDimension', () => {
|
||||
|
|
|
@ -39,12 +39,7 @@ import {
|
|||
getDatasourceSuggestionsForVisualizeField,
|
||||
} from './indexpattern_suggestions';
|
||||
|
||||
import {
|
||||
getInvalidColumnsForLayer,
|
||||
getInvalidLayers,
|
||||
isDraggedField,
|
||||
normalizeOperationDataType,
|
||||
} from './utils';
|
||||
import { isDraggedField, normalizeOperationDataType } from './utils';
|
||||
import { LayerPanel } from './layerpanel';
|
||||
import { IndexPatternColumn, getErrorMessages, IncompleteColumn } from './operations';
|
||||
import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types';
|
||||
|
@ -55,7 +50,6 @@ import { mergeLayer } from './state_helpers';
|
|||
import { Datasource, StateSetter } from '../index';
|
||||
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
|
||||
import { deleteColumn, isReferenced } from './operations';
|
||||
import { FieldBasedIndexPatternColumn } from './operations/definitions/column_types';
|
||||
import { Dragging } from '../drag_drop/providers';
|
||||
|
||||
export { OperationType, IndexPatternColumn, deleteColumn } from './operations';
|
||||
|
@ -162,10 +156,11 @@ export function getIndexPatternDatasource({
|
|||
},
|
||||
|
||||
removeColumn({ prevState, layerId, columnId }) {
|
||||
const indexPattern = prevState.indexPatterns[prevState.layers[layerId]?.indexPatternId];
|
||||
return mergeLayer({
|
||||
state: prevState,
|
||||
layerId,
|
||||
newLayer: deleteColumn({ layer: prevState.layers[layerId], columnId }),
|
||||
newLayer: deleteColumn({ layer: prevState.layers[layerId], columnId, indexPattern }),
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -351,7 +346,9 @@ export function getIndexPatternDatasource({
|
|||
const layer = state.layers[layerId];
|
||||
|
||||
if (layer && layer.columns[columnId]) {
|
||||
return columnToOperation(layer.columns[columnId], columnLabelMap[columnId]);
|
||||
if (!isReferenced(layer, columnId)) {
|
||||
return columnToOperation(layer.columns[columnId], columnLabelMap[columnId]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
@ -369,91 +366,46 @@ export function getIndexPatternDatasource({
|
|||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const invalidLayers = getInvalidLayers(state);
|
||||
|
||||
const layerErrors = Object.values(state.layers).flatMap((layer) =>
|
||||
const layerErrors = Object.values(state.layers).map((layer) =>
|
||||
(getErrorMessages(layer) ?? []).map((message) => ({
|
||||
shortMessage: message,
|
||||
longMessage: '',
|
||||
shortMessage: '', // Not displayed currently
|
||||
longMessage: message,
|
||||
}))
|
||||
);
|
||||
|
||||
if (invalidLayers.length === 0) {
|
||||
return layerErrors.length ? layerErrors : undefined;
|
||||
// Single layer case, no need to explain more
|
||||
if (layerErrors.length <= 1) {
|
||||
return layerErrors[0]?.length ? layerErrors[0] : undefined;
|
||||
}
|
||||
|
||||
const realIndex = Object.values(state.layers)
|
||||
.map((layer, i) => {
|
||||
const filteredIndex = invalidLayers.indexOf(layer);
|
||||
if (filteredIndex > -1) {
|
||||
return [filteredIndex, i + 1];
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<[number, number]>;
|
||||
const invalidColumnsForLayer: string[][] = getInvalidColumnsForLayer(
|
||||
invalidLayers,
|
||||
state.indexPatterns
|
||||
);
|
||||
const originalLayersList = Object.keys(state.layers);
|
||||
|
||||
if (layerErrors.length || realIndex.length) {
|
||||
return [
|
||||
...layerErrors,
|
||||
...realIndex.map(([filteredIndex, layerIndex]) => {
|
||||
const columnLabelsWithBrokenReferences: string[] = invalidColumnsForLayer[
|
||||
filteredIndex
|
||||
].map((columnId) => {
|
||||
const column = invalidLayers[filteredIndex].columns[
|
||||
columnId
|
||||
] as FieldBasedIndexPatternColumn;
|
||||
return column.label;
|
||||
});
|
||||
|
||||
if (originalLayersList.length === 1) {
|
||||
return {
|
||||
shortMessage: i18n.translate(
|
||||
'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer',
|
||||
{
|
||||
defaultMessage:
|
||||
'Invalid {columns, plural, one {reference} other {references}}.',
|
||||
values: {
|
||||
columns: columnLabelsWithBrokenReferences.length,
|
||||
},
|
||||
}
|
||||
),
|
||||
longMessage: i18n.translate(
|
||||
'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer',
|
||||
{
|
||||
defaultMessage: `"{columns}" {columnsLength, plural, one {has an} other {have}} invalid reference.`,
|
||||
values: {
|
||||
columns: columnLabelsWithBrokenReferences.join('", "'),
|
||||
columnsLength: columnLabelsWithBrokenReferences.length,
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', {
|
||||
defaultMessage:
|
||||
'Invalid {columnsLength, plural, one {reference} other {references}} on Layer {layer}.',
|
||||
values: {
|
||||
layer: layerIndex,
|
||||
columnsLength: columnLabelsWithBrokenReferences.length,
|
||||
},
|
||||
}),
|
||||
longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', {
|
||||
defaultMessage: `Layer {layer} has {columnsLength, plural, one {an invalid} other {invalid}} {columnsLength, plural, one {reference} other {references}} in "{columns}".`,
|
||||
values: {
|
||||
layer: layerIndex,
|
||||
columns: columnLabelsWithBrokenReferences.join('", "'),
|
||||
columnsLength: columnLabelsWithBrokenReferences.length,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
// For multiple layers we will prepend each error with the layer number
|
||||
const messages = layerErrors.flatMap((errors, index) => {
|
||||
return errors.map((error) => {
|
||||
const { shortMessage, longMessage } = error;
|
||||
return {
|
||||
shortMessage: shortMessage
|
||||
? i18n.translate('xpack.lens.indexPattern.layerErrorWrapper', {
|
||||
defaultMessage: 'Layer {position} error: {wrappedMessage}',
|
||||
values: {
|
||||
position: index + 1,
|
||||
wrappedMessage: shortMessage,
|
||||
},
|
||||
})
|
||||
: '',
|
||||
longMessage: longMessage
|
||||
? i18n.translate('xpack.lens.indexPattern.layerErrorWrapper', {
|
||||
defaultMessage: 'Layer {position} error: {wrappedMessage}',
|
||||
values: {
|
||||
position: index + 1,
|
||||
wrappedMessage: longMessage,
|
||||
},
|
||||
})
|
||||
: '',
|
||||
};
|
||||
});
|
||||
});
|
||||
return messages.length ? messages : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -6,11 +6,12 @@
|
|||
|
||||
import { DatasourceSuggestion } from '../types';
|
||||
import { generateId } from '../id_generator';
|
||||
import { IndexPatternPrivateState } from './types';
|
||||
import type { IndexPatternPrivateState } from './types';
|
||||
import {
|
||||
getDatasourceSuggestionsForField,
|
||||
getDatasourceSuggestionsFromCurrentState,
|
||||
getDatasourceSuggestionsForVisualizeField,
|
||||
IndexPatternSuggestion,
|
||||
} from './indexpattern_suggestions';
|
||||
import { documentField } from './document_field';
|
||||
import { getFieldByNameFactory } from './pure_helpers';
|
||||
|
@ -153,6 +154,7 @@ function testInitialState(): IndexPatternPrivateState {
|
|||
columns: {
|
||||
col1: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
|
@ -172,6 +174,19 @@ function testInitialState(): IndexPatternPrivateState {
|
|||
};
|
||||
}
|
||||
|
||||
// Simplifies the debug output for failed test
|
||||
function getSuggestionSubset(
|
||||
suggestions: IndexPatternSuggestion[]
|
||||
): Array<Omit<IndexPatternSuggestion, 'state'>> {
|
||||
return suggestions.map((s) => {
|
||||
const newSuggestion = { ...s } as Omit<IndexPatternSuggestion, 'state'> & {
|
||||
state?: IndexPatternPrivateState;
|
||||
};
|
||||
delete newSuggestion.state;
|
||||
return newSuggestion;
|
||||
});
|
||||
}
|
||||
|
||||
describe('IndexPattern Data Source suggestions', () => {
|
||||
beforeEach(async () => {
|
||||
let count = 0;
|
||||
|
@ -698,6 +713,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
isBucketed: true,
|
||||
sourceField: 'source',
|
||||
label: 'values of source',
|
||||
customLabel: true,
|
||||
operationType: 'terms',
|
||||
params: {
|
||||
orderBy: { type: 'column', columnId: 'colb' },
|
||||
|
@ -710,6 +726,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
isBucketed: false,
|
||||
sourceField: 'bytes',
|
||||
label: 'Avg of bytes',
|
||||
customLabel: true,
|
||||
operationType: 'avg',
|
||||
},
|
||||
},
|
||||
|
@ -733,7 +750,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
sourceField: 'timestamp',
|
||||
label: 'date histogram of timestamp',
|
||||
label: 'timestamp',
|
||||
operationType: 'date_histogram',
|
||||
params: {
|
||||
interval: 'w',
|
||||
|
@ -744,6 +761,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
isBucketed: false,
|
||||
sourceField: 'bytes',
|
||||
label: 'Avg of bytes',
|
||||
customLabel: true,
|
||||
operationType: 'avg',
|
||||
},
|
||||
},
|
||||
|
@ -782,6 +800,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
});
|
||||
|
||||
it('puts a date histogram column after the last bucket column on date field', () => {
|
||||
(generateId as jest.Mock).mockReturnValue('newid');
|
||||
const initialState = stateWithNonEmptyTables();
|
||||
const suggestions = getDatasourceSuggestionsForField(initialState, '1', {
|
||||
name: 'timestamp',
|
||||
|
@ -790,17 +809,16 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
aggregatable: true,
|
||||
searchable: true,
|
||||
});
|
||||
|
||||
expect(suggestions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
state: expect.objectContaining({
|
||||
layers: {
|
||||
previousLayer: initialState.layers.previousLayer,
|
||||
currentLayer: expect.objectContaining({
|
||||
columnOrder: ['cola', 'id1', 'colb'],
|
||||
columnOrder: ['cola', 'newid', 'colb'],
|
||||
columns: {
|
||||
...initialState.layers.currentLayer.columns,
|
||||
id1: expect.objectContaining({
|
||||
newid: expect.objectContaining({
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'timestamp',
|
||||
}),
|
||||
|
@ -817,7 +835,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
columnId: 'cola',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
columnId: 'id1',
|
||||
columnId: 'newid',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
columnId: 'colb',
|
||||
|
@ -845,6 +863,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
});
|
||||
|
||||
it('appends a terms column with default size on string field', () => {
|
||||
(generateId as jest.Mock).mockReturnValue('newid');
|
||||
const initialState = stateWithNonEmptyTables();
|
||||
const suggestions = getDatasourceSuggestionsForField(initialState, '1', {
|
||||
name: 'dest',
|
||||
|
@ -853,17 +872,16 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
aggregatable: true,
|
||||
searchable: true,
|
||||
});
|
||||
|
||||
expect(suggestions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
state: expect.objectContaining({
|
||||
layers: {
|
||||
previousLayer: initialState.layers.previousLayer,
|
||||
currentLayer: expect.objectContaining({
|
||||
columnOrder: ['cola', 'id1', 'colb'],
|
||||
columnOrder: ['cola', 'newid', 'colb'],
|
||||
columns: {
|
||||
...initialState.layers.currentLayer.columns,
|
||||
id1: expect.objectContaining({
|
||||
newid: expect.objectContaining({
|
||||
operationType: 'terms',
|
||||
sourceField: 'dest',
|
||||
params: expect.objectContaining({ size: 3 }),
|
||||
|
@ -877,6 +895,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
});
|
||||
|
||||
it('suggests both replacing and adding metric if only one other metric is set', () => {
|
||||
(generateId as jest.Mock).mockReturnValue('newid');
|
||||
const initialState = stateWithNonEmptyTables();
|
||||
const suggestions = getDatasourceSuggestionsForField(initialState, '1', {
|
||||
name: 'memory',
|
||||
|
@ -885,7 +904,6 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
aggregatable: true,
|
||||
searchable: true,
|
||||
});
|
||||
|
||||
expect(suggestions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
state: expect.objectContaining({
|
||||
|
@ -910,11 +928,11 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
state: expect.objectContaining({
|
||||
layers: expect.objectContaining({
|
||||
currentLayer: expect.objectContaining({
|
||||
columnOrder: ['cola', 'colb', 'id1'],
|
||||
columnOrder: ['cola', 'colb', 'newid'],
|
||||
columns: {
|
||||
cola: initialState.layers.currentLayer.columns.cola,
|
||||
colb: initialState.layers.currentLayer.columns.colb,
|
||||
id1: expect.objectContaining({
|
||||
newid: expect.objectContaining({
|
||||
operationType: 'avg',
|
||||
sourceField: 'memory',
|
||||
}),
|
||||
|
@ -927,6 +945,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
});
|
||||
|
||||
it('adds a metric column on a number field if no other metrics set', () => {
|
||||
(generateId as jest.Mock).mockReturnValue('newid');
|
||||
const initialState = stateWithNonEmptyTables();
|
||||
const modifiedState: IndexPatternPrivateState = {
|
||||
...initialState,
|
||||
|
@ -955,10 +974,10 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
layers: {
|
||||
previousLayer: modifiedState.layers.previousLayer,
|
||||
currentLayer: expect.objectContaining({
|
||||
columnOrder: ['cola', 'id1'],
|
||||
columnOrder: ['cola', 'newid'],
|
||||
columns: {
|
||||
...modifiedState.layers.currentLayer.columns,
|
||||
id1: expect.objectContaining({
|
||||
newid: expect.objectContaining({
|
||||
operationType: 'avg',
|
||||
sourceField: 'memory',
|
||||
}),
|
||||
|
@ -1008,6 +1027,137 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', documentField);
|
||||
expect(suggestions).not.toContain(expect.objectContaining({ changeType: 'extended' }));
|
||||
});
|
||||
|
||||
it('hides any referenced metrics when adding new metrics', () => {
|
||||
(generateId as jest.Mock).mockReturnValue('newid');
|
||||
const initialState = stateWithNonEmptyTables();
|
||||
const modifiedState: IndexPatternPrivateState = {
|
||||
...initialState,
|
||||
layers: {
|
||||
currentLayer: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['date', 'metric', 'ref'],
|
||||
columns: {
|
||||
date: {
|
||||
label: '',
|
||||
customLabel: true,
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'timestamp',
|
||||
params: { interval: 'auto' },
|
||||
},
|
||||
metric: {
|
||||
label: '',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
ref: {
|
||||
label: '',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['metric'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const suggestions = getSuggestionSubset(
|
||||
getDatasourceSuggestionsForField(modifiedState, '1', documentField)
|
||||
);
|
||||
expect(suggestions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: expect.objectContaining({
|
||||
isMultiRow: true,
|
||||
changeType: 'extended',
|
||||
label: undefined,
|
||||
layerId: 'currentLayer',
|
||||
columns: [
|
||||
{
|
||||
columnId: 'date',
|
||||
operation: expect.objectContaining({ dataType: 'date', isBucketed: true }),
|
||||
},
|
||||
{
|
||||
columnId: 'newid',
|
||||
operation: expect.objectContaining({ dataType: 'number', isBucketed: false }),
|
||||
},
|
||||
{
|
||||
columnId: 'ref',
|
||||
operation: expect.objectContaining({ dataType: 'number', isBucketed: false }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
keptLayerIds: ['currentLayer'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('makes a suggestion to extending from an invalid state with a new metric', () => {
|
||||
(generateId as jest.Mock).mockReturnValue('newid');
|
||||
const initialState = stateWithNonEmptyTables();
|
||||
const modifiedState: IndexPatternPrivateState = {
|
||||
...initialState,
|
||||
layers: {
|
||||
currentLayer: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['metric', 'ref'],
|
||||
columns: {
|
||||
metric: {
|
||||
label: '',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
ref: {
|
||||
label: '',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['metric'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const suggestions = getSuggestionSubset(
|
||||
getDatasourceSuggestionsForField(modifiedState, '1', documentField)
|
||||
);
|
||||
expect(suggestions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: expect.objectContaining({
|
||||
changeType: 'extended',
|
||||
columns: [
|
||||
{
|
||||
columnId: 'newid',
|
||||
operation: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Count of records',
|
||||
scale: 'ratio',
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'ref',
|
||||
operation: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: '',
|
||||
scale: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finding the layer that is using the current index pattern', () => {
|
||||
|
@ -1121,6 +1271,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getDatasourceSuggestionsForVisualizeField', () => {
|
||||
describe('with no layer', () => {
|
||||
function stateWithoutLayer() {
|
||||
|
@ -1218,6 +1369,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
columns: {
|
||||
cola: {
|
||||
label: 'My Op 2',
|
||||
customLabel: true,
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
|
@ -1305,6 +1457,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
columns: {
|
||||
cola: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
|
@ -1316,7 +1469,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
};
|
||||
|
||||
expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual(
|
||||
expect(getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state))).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: {
|
||||
isMultiRow: true,
|
||||
|
@ -1359,6 +1512,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
columns: {
|
||||
cola: {
|
||||
label: 'My Terms',
|
||||
customLabel: true,
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
operationType: 'terms',
|
||||
|
@ -1372,6 +1526,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
colb: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
|
@ -1383,7 +1538,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
};
|
||||
|
||||
expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual(
|
||||
expect(getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state))).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: {
|
||||
isMultiRow: true,
|
||||
|
@ -1442,6 +1597,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
colb: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: true,
|
||||
operationType: 'range',
|
||||
|
@ -1487,6 +1643,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
colb: {
|
||||
label: 'My Custom Range',
|
||||
customLabel: true,
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
operationType: 'range',
|
||||
|
@ -1503,7 +1660,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
};
|
||||
|
||||
expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual(
|
||||
expect(getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state))).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: {
|
||||
changeType: 'extended',
|
||||
|
@ -1555,6 +1712,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
columns: {
|
||||
id1: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'avg',
|
||||
|
@ -1631,6 +1789,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
columns: {
|
||||
col1: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
|
@ -1644,6 +1803,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
col2: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
|
@ -1657,6 +1817,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
col3: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
|
@ -1670,6 +1831,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
col4: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
|
@ -1678,6 +1840,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
col5: {
|
||||
label: 'My Op',
|
||||
customLabel: true,
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
|
@ -1691,31 +1854,26 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
};
|
||||
|
||||
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
|
||||
// 1 bucket col, 2 metric cols
|
||||
isTableWithBucketColumns(suggestions[0], ['col1', 'col4', 'col5'], 1);
|
||||
|
||||
// 3 bucket cols, 2 metric cols
|
||||
isTableWithBucketColumns(suggestions[0], ['col1', 'col2', 'col3', 'col4', 'col5'], 3);
|
||||
|
||||
// 1 bucket col, 1 metric col
|
||||
isTableWithBucketColumns(suggestions[1], ['col1', 'col4'], 1);
|
||||
|
||||
// 2 bucket cols, 2 metric cols
|
||||
isTableWithBucketColumns(suggestions[2], ['col1', 'col2', 'col4', 'col5'], 2);
|
||||
|
||||
// 2 bucket cols, 1 metric col
|
||||
isTableWithBucketColumns(suggestions[3], ['col1', 'col2', 'col4'], 2);
|
||||
|
||||
// 3 bucket cols, 2 metric cols
|
||||
isTableWithBucketColumns(suggestions[4], ['col1', 'col2', 'col3', 'col4', 'col5'], 3);
|
||||
isTableWithBucketColumns(suggestions[2], ['col1', 'col2', 'col4'], 2);
|
||||
|
||||
// 3 bucket cols, 1 metric col
|
||||
isTableWithBucketColumns(suggestions[5], ['col1', 'col2', 'col3', 'col4'], 3);
|
||||
isTableWithBucketColumns(suggestions[3], ['col1', 'col2', 'col3', 'col4'], 3);
|
||||
|
||||
// first metric col
|
||||
isTableWithMetricColumns(suggestions[6], ['col4']);
|
||||
isTableWithMetricColumns(suggestions[4], ['col4']);
|
||||
|
||||
// second metric col
|
||||
isTableWithMetricColumns(suggestions[7], ['col5']);
|
||||
isTableWithMetricColumns(suggestions[5], ['col5']);
|
||||
|
||||
expect(suggestions.length).toBe(8);
|
||||
expect(suggestions.length).toBe(6);
|
||||
});
|
||||
|
||||
it('returns an only metric version of a given table', () => {
|
||||
|
@ -1770,7 +1928,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
...initialState.layers.first,
|
||||
columns: {
|
||||
id1: {
|
||||
label: 'Date histogram',
|
||||
label: 'field2',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
|
||||
|
@ -1794,8 +1952,19 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
|
||||
expect(suggestions[1].table.columns[0].operation.label).toBe('Average of field1');
|
||||
const suggestions = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
|
||||
expect(suggestions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: expect.objectContaining({
|
||||
changeType: 'reduced',
|
||||
columns: [
|
||||
expect.objectContaining({
|
||||
operation: expect.objectContaining({ label: 'Average of field1' }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an alternative metric for an only-metric table', () => {
|
||||
|
@ -1848,9 +2017,18 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
|
||||
expect(suggestions[0].table.columns.length).toBe(1);
|
||||
expect(suggestions[0].table.columns[0].operation.label).toBe('Sum of field1');
|
||||
const suggestions = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
|
||||
expect(suggestions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: expect.objectContaining({
|
||||
columns: [
|
||||
expect.objectContaining({
|
||||
operation: expect.objectContaining({ label: 'Sum of field1' }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('contains a reordering suggestion when there are exactly 2 buckets', () => {
|
||||
|
@ -1909,7 +2087,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not generate suggestions if invalid fields are referenced', () => {
|
||||
it('will generate suggestions even if there are errors from missing fields', () => {
|
||||
const initialState = testInitialState();
|
||||
const state: IndexPatternPrivateState = {
|
||||
indexPatternRefs: [],
|
||||
|
@ -1937,8 +2115,259 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
|
||||
expect(suggestions).toEqual([]);
|
||||
const suggestions = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
|
||||
expect(suggestions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: {
|
||||
changeType: 'unchanged',
|
||||
columns: [
|
||||
{
|
||||
columnId: 'col1',
|
||||
operation: {
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
label: 'My Op',
|
||||
scale: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'col2',
|
||||
operation: {
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
label: 'Top 5',
|
||||
scale: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
isMultiRow: true,
|
||||
label: undefined,
|
||||
layerId: 'first',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('references', () => {
|
||||
it('will extend the table with a date when starting in an invalid state', () => {
|
||||
const initialState = testInitialState();
|
||||
const state: IndexPatternPrivateState = {
|
||||
...initialState,
|
||||
layers: {
|
||||
...initialState.layers,
|
||||
first: {
|
||||
...initialState.layers.first,
|
||||
columnOrder: ['metric', 'ref', 'ref2'],
|
||||
columns: {
|
||||
metric: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
},
|
||||
ref: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['metric'],
|
||||
},
|
||||
ref2: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['metric2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: expect.objectContaining({
|
||||
changeType: 'extended',
|
||||
layerId: 'first',
|
||||
columns: [
|
||||
{
|
||||
columnId: 'id1',
|
||||
operation: {
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: 'timestampLabel',
|
||||
scale: 'interval',
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'ref',
|
||||
operation: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Cumulative sum of Records',
|
||||
scale: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'ref2',
|
||||
operation: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Cumulative sum of (incomplete)',
|
||||
scale: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
keptLayerIds: ['first'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('will make an unchanged suggestion including incomplete references', () => {
|
||||
const initialState = testInitialState();
|
||||
const state: IndexPatternPrivateState = {
|
||||
...initialState,
|
||||
layers: {
|
||||
...initialState.layers,
|
||||
first: {
|
||||
...initialState.layers.first,
|
||||
columnOrder: ['date', 'ref', 'ref2'],
|
||||
columns: {
|
||||
date: {
|
||||
label: '',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'timestamp',
|
||||
params: { interval: 'auto' },
|
||||
},
|
||||
ref: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['metric'],
|
||||
},
|
||||
ref2: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['metric'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: expect.objectContaining({
|
||||
changeType: 'unchanged',
|
||||
layerId: 'first',
|
||||
columns: [
|
||||
{
|
||||
columnId: 'date',
|
||||
operation: {
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: '',
|
||||
scale: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'ref',
|
||||
operation: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: '',
|
||||
scale: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'ref2',
|
||||
operation: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: '',
|
||||
scale: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
keptLayerIds: ['first'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('will skip a reduced suggestion when handling multiple references', () => {
|
||||
const initialState = testInitialState();
|
||||
const state: IndexPatternPrivateState = {
|
||||
...initialState,
|
||||
layers: {
|
||||
...initialState.layers,
|
||||
first: {
|
||||
...initialState.layers.first,
|
||||
columnOrder: ['date', 'metric', 'metric2', 'ref', 'ref2'],
|
||||
|
||||
columns: {
|
||||
date: {
|
||||
label: '',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'timestamp',
|
||||
params: { interval: 'auto' },
|
||||
},
|
||||
metric: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
},
|
||||
ref: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['metric'],
|
||||
},
|
||||
metric2: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
},
|
||||
ref2: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['metric2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state));
|
||||
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: expect.objectContaining({
|
||||
changeType: 'reduced',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import _, { partition } from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { generateId } from '../id_generator';
|
||||
import { DatasourceSuggestion, TableChangeType } from '../types';
|
||||
|
@ -17,8 +17,10 @@ import {
|
|||
operationDefinitionMap,
|
||||
IndexPatternColumn,
|
||||
OperationType,
|
||||
getExistingColumnGroups,
|
||||
isReferenced,
|
||||
} from './operations';
|
||||
import { hasField, hasInvalidColumns } from './utils';
|
||||
import { hasField } from './utils';
|
||||
import {
|
||||
IndexPattern,
|
||||
IndexPatternPrivateState,
|
||||
|
@ -27,7 +29,7 @@ import {
|
|||
} from './types';
|
||||
import { documentField } from './document_field';
|
||||
|
||||
type IndexPatternSugestion = DatasourceSuggestion<IndexPatternPrivateState>;
|
||||
export type IndexPatternSuggestion = DatasourceSuggestion<IndexPatternPrivateState>;
|
||||
|
||||
function buildSuggestion({
|
||||
state,
|
||||
|
@ -71,10 +73,13 @@ function buildSuggestion({
|
|||
},
|
||||
|
||||
table: {
|
||||
columns: columnOrder.map((columnId) => ({
|
||||
columnId,
|
||||
operation: columnToOperation(columnMap[columnId]),
|
||||
})),
|
||||
columns: columnOrder
|
||||
// Hide any referenced columns from what visualizations know about
|
||||
.filter((columnId) => !isReferenced(layers[layerId]!, columnId))
|
||||
.map((columnId) => ({
|
||||
columnId,
|
||||
operation: columnToOperation(columnMap[columnId]),
|
||||
})),
|
||||
isMultiRow,
|
||||
layerId,
|
||||
changeType,
|
||||
|
@ -89,8 +94,7 @@ export function getDatasourceSuggestionsForField(
|
|||
state: IndexPatternPrivateState,
|
||||
indexPatternId: string,
|
||||
field: IndexPatternField
|
||||
): IndexPatternSugestion[] {
|
||||
if (hasInvalidColumns(state)) return [];
|
||||
): IndexPatternSuggestion[] {
|
||||
const layers = Object.keys(state.layers);
|
||||
const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
|
||||
|
||||
|
@ -123,7 +127,7 @@ export function getDatasourceSuggestionsForVisualizeField(
|
|||
state: IndexPatternPrivateState,
|
||||
indexPatternId: string,
|
||||
fieldName: string
|
||||
): IndexPatternSugestion[] {
|
||||
): IndexPatternSuggestion[] {
|
||||
const layers = Object.keys(state.layers);
|
||||
const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
|
||||
// Identify the field by the indexPatternId and the fieldName
|
||||
|
@ -158,7 +162,7 @@ function getExistingLayerSuggestionsForField(
|
|||
const fieldInUse = Object.values(layer.columns).some(
|
||||
(column) => hasField(column) && column.sourceField === field.name
|
||||
);
|
||||
const suggestions: IndexPatternSugestion[] = [];
|
||||
const suggestions: IndexPatternSuggestion[] = [];
|
||||
|
||||
if (usableAsBucketOperation && !fieldInUse) {
|
||||
if (
|
||||
|
@ -221,8 +225,9 @@ function getExistingLayerSuggestionsForField(
|
|||
);
|
||||
}
|
||||
|
||||
const [, metrics] = separateBucketColumns(layer);
|
||||
if (metrics.length === 1) {
|
||||
const [, metrics, references] = getExistingColumnGroups(layer);
|
||||
// TODO: Write test for the case where we have exactly one metric and one reference. We shouldn't switch the inner metric.
|
||||
if (metrics.length === 1 && references.length === 0) {
|
||||
const layerWithReplacedMetric = replaceColumn({
|
||||
layer,
|
||||
indexPattern,
|
||||
|
@ -257,7 +262,7 @@ function getEmptyLayerSuggestionsForField(
|
|||
layerId: string,
|
||||
indexPatternId: string,
|
||||
field: IndexPatternField
|
||||
): IndexPatternSugestion[] {
|
||||
): IndexPatternSuggestion[] {
|
||||
const indexPattern = state.indexPatterns[indexPatternId];
|
||||
let newLayer: IndexPatternLayer | undefined;
|
||||
const bucketOperation = getBucketOperation(field);
|
||||
|
@ -331,7 +336,6 @@ function createNewLayerWithMetricAggregation(
|
|||
export function getDatasourceSuggestionsFromCurrentState(
|
||||
state: IndexPatternPrivateState
|
||||
): Array<DatasourceSuggestion<IndexPatternPrivateState>> {
|
||||
if (hasInvalidColumns(state)) return [];
|
||||
const layers = Object.entries(state.layers || {});
|
||||
if (layers.length > 1) {
|
||||
// Return suggestions that reduce the data to each layer individually
|
||||
|
@ -372,12 +376,13 @@ export function getDatasourceSuggestionsFromCurrentState(
|
|||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return _.flatten(
|
||||
Object.entries(state.layers || {})
|
||||
.filter(([_id, layer]) => layer.columnOrder.length && layer.indexPatternId)
|
||||
.map(([layerId, layer]) => {
|
||||
const indexPattern = state.indexPatterns[layer.indexPatternId];
|
||||
const [buckets, metrics] = separateBucketColumns(layer);
|
||||
const [buckets, metrics, references] = getExistingColumnGroups(layer);
|
||||
const timeDimension = layer.columnOrder.find(
|
||||
(columnId) =>
|
||||
layer.columns[columnId].isBucketed && layer.columns[columnId].dataType === 'date'
|
||||
|
@ -390,29 +395,22 @@ export function getDatasourceSuggestionsFromCurrentState(
|
|||
buckets.some((columnId) => layer.columns[columnId].dataType === 'number');
|
||||
|
||||
const suggestions: Array<DatasourceSuggestion<IndexPatternPrivateState>> = [];
|
||||
if (metrics.length === 0) {
|
||||
// intermediary chart without metric, don't try to suggest reduced versions
|
||||
suggestions.push(
|
||||
buildSuggestion({
|
||||
state,
|
||||
layerId,
|
||||
changeType: 'unchanged',
|
||||
})
|
||||
);
|
||||
} else if (buckets.length === 0) {
|
||||
|
||||
// Always suggest an unchanged table, including during invalid states
|
||||
suggestions.push(
|
||||
buildSuggestion({
|
||||
state,
|
||||
layerId,
|
||||
changeType: 'unchanged',
|
||||
})
|
||||
);
|
||||
|
||||
if (!references.length && metrics.length && buckets.length === 0) {
|
||||
if (timeField) {
|
||||
// suggest current metric over time if there is a default time field
|
||||
suggestions.push(createSuggestionWithDefaultDateHistogram(state, layerId, timeField));
|
||||
}
|
||||
suggestions.push(...createAlternativeMetricSuggestions(indexPattern, layerId, state));
|
||||
// also suggest simple current state
|
||||
suggestions.push(
|
||||
buildSuggestion({
|
||||
state,
|
||||
layerId,
|
||||
changeType: 'unchanged',
|
||||
})
|
||||
);
|
||||
} else {
|
||||
suggestions.push(...createSimplifiedTableSuggestions(state, layerId));
|
||||
|
||||
|
@ -570,7 +568,11 @@ function createSuggestionWithDefaultDateHistogram(
|
|||
function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layerId: string) {
|
||||
const layer = state.layers[layerId];
|
||||
|
||||
const [availableBucketedColumns, availableMetricColumns] = separateBucketColumns(layer);
|
||||
const [
|
||||
availableBucketedColumns,
|
||||
availableMetricColumns,
|
||||
availableReferenceColumns,
|
||||
] = getExistingColumnGroups(layer);
|
||||
|
||||
return _.flatten(
|
||||
availableBucketedColumns.map((_col, index) => {
|
||||
|
@ -581,21 +583,23 @@ function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layer
|
|||
columnOrder: [...bucketedColumns, ...availableMetricColumns],
|
||||
};
|
||||
|
||||
if (availableMetricColumns.length > 1) {
|
||||
return [
|
||||
allMetricsSuggestion,
|
||||
{ ...layer, columnOrder: [...bucketedColumns, availableMetricColumns[0]] },
|
||||
];
|
||||
if (availableReferenceColumns.length) {
|
||||
// Don't remove buckets when dealing with any refs. This can break refs.
|
||||
return [];
|
||||
} else if (availableMetricColumns.length > 1) {
|
||||
return [{ ...layer, columnOrder: [...bucketedColumns, availableMetricColumns[0]] }];
|
||||
} else {
|
||||
return allMetricsSuggestion;
|
||||
}
|
||||
})
|
||||
)
|
||||
.concat(
|
||||
availableMetricColumns.map((columnId) => {
|
||||
// build suggestions with only metrics
|
||||
return { ...layer, columnOrder: [columnId] };
|
||||
})
|
||||
availableReferenceColumns.length
|
||||
? []
|
||||
: availableMetricColumns.map((columnId) => {
|
||||
// build suggestions with only metrics
|
||||
return { ...layer, columnOrder: [columnId] };
|
||||
})
|
||||
)
|
||||
.map((updatedLayer) => {
|
||||
return buildSuggestion({
|
||||
|
@ -623,7 +627,3 @@ function getMetricSuggestionTitle(layer: IndexPatternLayer, onlyMetric: boolean)
|
|||
'Title of a suggested chart containing only a single numerical metric calculated over all available data',
|
||||
});
|
||||
}
|
||||
|
||||
function separateBucketColumns(layer: IndexPatternLayer) {
|
||||
return partition(layer.columnOrder, (columnId) => layer.columns[columnId].isBucketed);
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ export const {
|
|||
getErrorMessages,
|
||||
isReferenced,
|
||||
resetIncomplete,
|
||||
isOperationAllowedAsReference,
|
||||
} = actualHelpers;
|
||||
|
||||
export const { adjustTimeScaleLabelSuffix, DEFAULT_TIME_SCALE } = actualTimeScaleUtils;
|
||||
|
|
|
@ -9,6 +9,7 @@ import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '.
|
|||
import { IndexPatternLayer } from '../../../types';
|
||||
import {
|
||||
buildLabelFunction,
|
||||
getErrorsForDateReference,
|
||||
checkForDateHistogram,
|
||||
dateBasedOperationToExpression,
|
||||
hasDateField,
|
||||
|
@ -52,15 +53,18 @@ export const counterRateOperation: OperationDefinition<
|
|||
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
|
||||
},
|
||||
],
|
||||
getPossibleOperation: () => {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
getPossibleOperation: (indexPattern) => {
|
||||
if (hasDateField(indexPattern)) {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) => {
|
||||
return ofName(columns[column.references[0]]?.label, column.timeScale);
|
||||
const ref = columns[column.references[0]];
|
||||
return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined, column.timeScale);
|
||||
},
|
||||
toExpression: (layer, columnId) => {
|
||||
return dateBasedOperationToExpression(layer, columnId, 'lens_counter_rate');
|
||||
|
@ -69,7 +73,7 @@ export const counterRateOperation: OperationDefinition<
|
|||
const metric = layer.columns[referenceIds[0]];
|
||||
const timeScale = previousColumn?.timeScale || DEFAULT_TIME_SCALE;
|
||||
return {
|
||||
label: ofName(metric?.label, timeScale),
|
||||
label: ofName(metric && 'sourceField' in metric ? metric.sourceField : undefined, timeScale),
|
||||
dataType: 'number',
|
||||
operationType: 'counter_rate',
|
||||
isBucketed: false,
|
||||
|
@ -88,13 +92,22 @@ export const counterRateOperation: OperationDefinition<
|
|||
isTransferable: (column, newIndexPattern) => {
|
||||
return hasDateField(newIndexPattern);
|
||||
},
|
||||
getErrorMessage: (layer: IndexPatternLayer) => {
|
||||
return checkForDateHistogram(
|
||||
getErrorMessage: (layer: IndexPatternLayer, columnId: string) => {
|
||||
return getErrorsForDateReference(
|
||||
layer,
|
||||
columnId,
|
||||
i18n.translate('xpack.lens.indexPattern.counterRate', {
|
||||
defaultMessage: 'Counter rate',
|
||||
})
|
||||
);
|
||||
},
|
||||
getDisabledStatus(indexPattern, layer) {
|
||||
return checkForDateHistogram(
|
||||
layer,
|
||||
i18n.translate('xpack.lens.indexPattern.counterRate', {
|
||||
defaultMessage: 'Counter rate',
|
||||
})
|
||||
)?.join(', ');
|
||||
},
|
||||
timeScalingMode: 'mandatory',
|
||||
};
|
||||
|
|
|
@ -7,12 +7,17 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
|
||||
import { IndexPatternLayer } from '../../../types';
|
||||
import { checkForDateHistogram, dateBasedOperationToExpression } from './utils';
|
||||
import {
|
||||
checkForDateHistogram,
|
||||
getErrorsForDateReference,
|
||||
dateBasedOperationToExpression,
|
||||
hasDateField,
|
||||
} from './utils';
|
||||
import { OperationDefinition } from '..';
|
||||
|
||||
const ofName = (name?: string) => {
|
||||
return i18n.translate('xpack.lens.indexPattern.cumulativeSumOf', {
|
||||
defaultMessage: 'Cumulative sum rate of {name}',
|
||||
defaultMessage: 'Cumulative sum of {name}',
|
||||
values: {
|
||||
name:
|
||||
name ??
|
||||
|
@ -46,23 +51,26 @@ export const cumulativeSumOperation: OperationDefinition<
|
|||
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
|
||||
},
|
||||
],
|
||||
getPossibleOperation: () => {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
getPossibleOperation: (indexPattern) => {
|
||||
if (hasDateField(indexPattern)) {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) => {
|
||||
return ofName(columns[column.references[0]]?.label);
|
||||
const ref = columns[column.references[0]];
|
||||
return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined);
|
||||
},
|
||||
toExpression: (layer, columnId) => {
|
||||
return dateBasedOperationToExpression(layer, columnId, 'cumulative_sum');
|
||||
},
|
||||
buildColumn: ({ referenceIds, previousColumn, layer }) => {
|
||||
const metric = layer.columns[referenceIds[0]];
|
||||
const ref = layer.columns[referenceIds[0]];
|
||||
return {
|
||||
label: ofName(metric?.label),
|
||||
label: ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined),
|
||||
dataType: 'number',
|
||||
operationType: 'cumulative_sum',
|
||||
isBucketed: false,
|
||||
|
@ -80,12 +88,21 @@ export const cumulativeSumOperation: OperationDefinition<
|
|||
isTransferable: () => {
|
||||
return true;
|
||||
},
|
||||
getErrorMessage: (layer: IndexPatternLayer) => {
|
||||
return checkForDateHistogram(
|
||||
getErrorMessage: (layer: IndexPatternLayer, columnId: string) => {
|
||||
return getErrorsForDateReference(
|
||||
layer,
|
||||
columnId,
|
||||
i18n.translate('xpack.lens.indexPattern.cumulativeSum', {
|
||||
defaultMessage: 'Cumulative sum',
|
||||
})
|
||||
);
|
||||
},
|
||||
getDisabledStatus(indexPattern, layer) {
|
||||
return checkForDateHistogram(
|
||||
layer,
|
||||
i18n.translate('xpack.lens.indexPattern.cumulativeSum', {
|
||||
defaultMessage: 'Cumulative sum',
|
||||
})
|
||||
)?.join(', ');
|
||||
},
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ import { IndexPatternLayer } from '../../../types';
|
|||
import {
|
||||
buildLabelFunction,
|
||||
checkForDateHistogram,
|
||||
getErrorsForDateReference,
|
||||
dateBasedOperationToExpression,
|
||||
hasDateField,
|
||||
} from './utils';
|
||||
|
@ -51,23 +52,29 @@ export const derivativeOperation: OperationDefinition<
|
|||
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
|
||||
},
|
||||
],
|
||||
getPossibleOperation: () => {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
getPossibleOperation: (indexPattern) => {
|
||||
if (hasDateField(indexPattern)) {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) => {
|
||||
return ofName(columns[column.references[0]]?.label, column.timeScale);
|
||||
const ref = columns[column.references[0]];
|
||||
return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined, column.timeScale);
|
||||
},
|
||||
toExpression: (layer, columnId) => {
|
||||
return dateBasedOperationToExpression(layer, columnId, 'derivative');
|
||||
},
|
||||
buildColumn: ({ referenceIds, previousColumn, layer }) => {
|
||||
const metric = layer.columns[referenceIds[0]];
|
||||
const ref = layer.columns[referenceIds[0]];
|
||||
return {
|
||||
label: ofName(metric?.label, previousColumn?.timeScale),
|
||||
label: ofName(
|
||||
ref && 'sourceField' in ref ? ref.sourceField : undefined,
|
||||
previousColumn?.timeScale
|
||||
),
|
||||
dataType: 'number',
|
||||
operationType: 'derivative',
|
||||
isBucketed: false,
|
||||
|
@ -87,13 +94,22 @@ export const derivativeOperation: OperationDefinition<
|
|||
return hasDateField(newIndexPattern);
|
||||
},
|
||||
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
|
||||
getErrorMessage: (layer: IndexPatternLayer) => {
|
||||
return checkForDateHistogram(
|
||||
getErrorMessage: (layer: IndexPatternLayer, columnId: string) => {
|
||||
return getErrorsForDateReference(
|
||||
layer,
|
||||
columnId,
|
||||
i18n.translate('xpack.lens.indexPattern.derivative', {
|
||||
defaultMessage: 'Differences',
|
||||
})
|
||||
);
|
||||
},
|
||||
getDisabledStatus(indexPattern, layer) {
|
||||
return checkForDateHistogram(
|
||||
layer,
|
||||
i18n.translate('xpack.lens.indexPattern.derivative', {
|
||||
defaultMessage: 'Differences',
|
||||
})
|
||||
)?.join(', ');
|
||||
},
|
||||
timeScalingMode: 'optional',
|
||||
};
|
||||
|
|
|
@ -14,6 +14,7 @@ import { IndexPatternLayer } from '../../../types';
|
|||
import {
|
||||
buildLabelFunction,
|
||||
checkForDateHistogram,
|
||||
getErrorsForDateReference,
|
||||
dateBasedOperationToExpression,
|
||||
hasDateField,
|
||||
} from './utils';
|
||||
|
@ -50,7 +51,7 @@ export const movingAverageOperation: OperationDefinition<
|
|||
type: 'moving_average',
|
||||
priority: 1,
|
||||
displayName: i18n.translate('xpack.lens.indexPattern.movingAverage', {
|
||||
defaultMessage: 'Moving Average',
|
||||
defaultMessage: 'Moving average',
|
||||
}),
|
||||
input: 'fullReference',
|
||||
selectionStyle: 'full',
|
||||
|
@ -60,12 +61,14 @@ export const movingAverageOperation: OperationDefinition<
|
|||
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
|
||||
},
|
||||
],
|
||||
getPossibleOperation: () => {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
getPossibleOperation: (indexPattern) => {
|
||||
if (hasDateField(indexPattern)) {
|
||||
return {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) => {
|
||||
return ofName(columns[column.references[0]]?.label, column.timeScale);
|
||||
|
@ -99,17 +102,39 @@ export const movingAverageOperation: OperationDefinition<
|
|||
return hasDateField(newIndexPattern);
|
||||
},
|
||||
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
|
||||
getErrorMessage: (layer: IndexPatternLayer) => {
|
||||
return checkForDateHistogram(
|
||||
getErrorMessage: (layer: IndexPatternLayer, columnId: string) => {
|
||||
return getErrorsForDateReference(
|
||||
layer,
|
||||
columnId,
|
||||
i18n.translate('xpack.lens.indexPattern.movingAverage', {
|
||||
defaultMessage: 'Moving Average',
|
||||
defaultMessage: 'Moving average',
|
||||
})
|
||||
);
|
||||
},
|
||||
getDisabledStatus(indexPattern, layer) {
|
||||
return checkForDateHistogram(
|
||||
layer,
|
||||
i18n.translate('xpack.lens.indexPattern.movingAverage', {
|
||||
defaultMessage: 'Moving average',
|
||||
})
|
||||
)?.join(', ');
|
||||
},
|
||||
timeScalingMode: 'optional',
|
||||
};
|
||||
|
||||
function isValidNumber(input: string) {
|
||||
if (input === '') return false;
|
||||
try {
|
||||
const val = parseFloat(input);
|
||||
if (isNaN(val)) return false;
|
||||
if (val < 1) return false;
|
||||
if (val.toString().includes('.')) return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function MovingAverageParamEditor({
|
||||
layer,
|
||||
updateLayer,
|
||||
|
@ -120,10 +145,8 @@ function MovingAverageParamEditor({
|
|||
|
||||
useDebounceWithOptions(
|
||||
() => {
|
||||
if (inputValue === '') {
|
||||
return;
|
||||
}
|
||||
const inputNumber = Number(inputValue);
|
||||
if (!isValidNumber(inputValue)) return;
|
||||
const inputNumber = parseInt(inputValue, 10);
|
||||
updateLayer(
|
||||
updateColumnParam({
|
||||
layer,
|
||||
|
@ -137,6 +160,7 @@ function MovingAverageParamEditor({
|
|||
256,
|
||||
[inputValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.lens.indexPattern.movingAverage.window', {
|
||||
|
@ -144,11 +168,15 @@ function MovingAverageParamEditor({
|
|||
})}
|
||||
display="columnCompressed"
|
||||
fullWidth
|
||||
isInvalid={!isValidNumber(inputValue)}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
compressed
|
||||
value={inputValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value)}
|
||||
min={1}
|
||||
step={1}
|
||||
isInvalid={!isValidNumber(inputValue)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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 { checkReferences } from './utils';
|
||||
import { operationDefinitionMap } from '..';
|
||||
import { createMockedReferenceOperation } from '../../mocks';
|
||||
|
||||
// Mock prevents issue with circular loading
|
||||
jest.mock('..');
|
||||
|
||||
describe('utils', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error test-only operation type
|
||||
operationDefinitionMap.testReference = createMockedReferenceOperation();
|
||||
});
|
||||
|
||||
describe('checkReferences', () => {
|
||||
it('should show an error if the reference is missing', () => {
|
||||
expect(
|
||||
checkReferences(
|
||||
{
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Label',
|
||||
// @ts-expect-error test-only operation type
|
||||
operationType: 'testReference',
|
||||
isBucketed: false,
|
||||
dataType: 'number',
|
||||
references: ['missing'],
|
||||
},
|
||||
},
|
||||
columnOrder: ['ref'],
|
||||
indexPatternId: '',
|
||||
},
|
||||
'ref'
|
||||
)
|
||||
).toEqual(['"Label" is not fully configured']);
|
||||
});
|
||||
|
||||
it('should show an error if the reference is not allowed per the requirements', () => {
|
||||
expect(
|
||||
checkReferences(
|
||||
{
|
||||
columns: {
|
||||
ref: {
|
||||
label: 'Label',
|
||||
// @ts-expect-error test-only operation type
|
||||
operationType: 'testReference',
|
||||
isBucketed: false,
|
||||
dataType: 'number',
|
||||
references: ['invalid'],
|
||||
},
|
||||
invalid: {
|
||||
label: 'Date',
|
||||
operationType: 'date_histogram',
|
||||
isBucketed: true,
|
||||
dataType: 'date',
|
||||
sourceField: 'timestamp',
|
||||
params: { interval: 'auto' },
|
||||
},
|
||||
},
|
||||
columnOrder: ['invalid', 'ref'],
|
||||
indexPatternId: '',
|
||||
},
|
||||
'ref'
|
||||
)
|
||||
).toEqual(['Dimension "Label" is configured incorrectly']);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,11 +5,13 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ExpressionFunctionAST } from '@kbn/interpreter/common';
|
||||
import { TimeScaleUnit } from '../../../time_scale';
|
||||
import { IndexPattern, IndexPatternLayer } from '../../../types';
|
||||
import type { ExpressionFunctionAST } from '@kbn/interpreter/common';
|
||||
import type { TimeScaleUnit } from '../../../time_scale';
|
||||
import type { IndexPattern, IndexPatternLayer } from '../../../types';
|
||||
import { adjustTimeScaleLabelSuffix } from '../../time_scale_utils';
|
||||
import { ReferenceBasedIndexPatternColumn } from '../column_types';
|
||||
import type { ReferenceBasedIndexPatternColumn } from '../column_types';
|
||||
import { operationDefinitionMap } from '..';
|
||||
import type { IndexPatternColumn, RequiredReference } from '..';
|
||||
|
||||
export const buildLabelFunction = (ofName: (name?: string) => string) => (
|
||||
name?: string,
|
||||
|
@ -41,6 +43,78 @@ export function checkForDateHistogram(layer: IndexPatternLayer, name: string) {
|
|||
];
|
||||
}
|
||||
|
||||
export function checkReferences(layer: IndexPatternLayer, columnId: string) {
|
||||
const column = layer.columns[columnId] as ReferenceBasedIndexPatternColumn;
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
column.references.forEach((referenceId, index) => {
|
||||
if (!layer.columns[referenceId]) {
|
||||
errors.push(
|
||||
i18n.translate('xpack.lens.indexPattern.missingReferenceError', {
|
||||
defaultMessage: '"{dimensionLabel}" is not fully configured',
|
||||
values: {
|
||||
dimensionLabel: column.label,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const referenceColumn = layer.columns[referenceId]!;
|
||||
const definition = operationDefinitionMap[column.operationType];
|
||||
if (definition.input !== 'fullReference') {
|
||||
throw new Error('inconsistent state - column is not a reference operation');
|
||||
}
|
||||
const requirements = definition.requiredReferences[index];
|
||||
const isValid = isColumnValidAsReference({
|
||||
validation: requirements,
|
||||
column: referenceColumn,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
errors.push(
|
||||
i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', {
|
||||
defaultMessage: 'Dimension "{dimensionLabel}" is configured incorrectly',
|
||||
values: {
|
||||
dimensionLabel: column.label,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return errors.length ? errors : undefined;
|
||||
}
|
||||
|
||||
export function isColumnValidAsReference({
|
||||
column,
|
||||
validation,
|
||||
}: {
|
||||
column: IndexPatternColumn;
|
||||
validation: RequiredReference;
|
||||
}): boolean {
|
||||
if (!column) return false;
|
||||
const operationType = column.operationType;
|
||||
const operationDefinition = operationDefinitionMap[operationType];
|
||||
return (
|
||||
validation.input.includes(operationDefinition.input) &&
|
||||
(!validation.specificOperations || validation.specificOperations.includes(operationType)) &&
|
||||
validation.validateMetadata(column)
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorsForDateReference(
|
||||
layer: IndexPatternLayer,
|
||||
columnId: string,
|
||||
name: string
|
||||
) {
|
||||
const dateErrors = checkForDateHistogram(layer, name) ?? [];
|
||||
const referenceErrors = checkReferences(layer, columnId) ?? [];
|
||||
if (dateErrors.length || referenceErrors.length) {
|
||||
return [...dateErrors, ...referenceErrors];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export function hasDateField(indexPattern: IndexPattern) {
|
||||
return indexPattern.fields.some((field) => field.type === 'date');
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { buildExpressionFunction } from '../../../../../../../src/plugins/expres
|
|||
import { OperationDefinition } from './index';
|
||||
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
|
||||
|
||||
import { getInvalidFieldMessage } from './helpers';
|
||||
import { getInvalidFieldMessage, getSafeName } from './helpers';
|
||||
|
||||
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
|
||||
|
||||
|
@ -21,7 +21,9 @@ const IS_BUCKETED = false;
|
|||
function ofName(name: string) {
|
||||
return i18n.translate('xpack.lens.indexPattern.cardinalityOf', {
|
||||
defaultMessage: 'Unique count of {name}',
|
||||
values: { name },
|
||||
values: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -58,8 +60,7 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo
|
|||
(!newField.aggregationRestrictions || newField.aggregationRestrictions.cardinality)
|
||||
);
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
ofName(indexPattern.getFieldByName(column.sourceField)!.displayName),
|
||||
getDefaultLabel: (column, indexPattern) => ofName(getSafeName(column.sourceField, indexPattern)),
|
||||
buildColumn({ field, previousColumn }) {
|
||||
return {
|
||||
label: ofName(field.displayName),
|
||||
|
|
|
@ -69,7 +69,12 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
|
|||
: undefined,
|
||||
};
|
||||
},
|
||||
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
|
||||
onOtherColumnChanged: (layer, thisColumnId, changedColumnId) =>
|
||||
adjustTimeScaleOnOtherColumnChange<CountIndexPatternColumn>(
|
||||
layer,
|
||||
thisColumnId,
|
||||
changedColumnId
|
||||
),
|
||||
toEsAggsFn: (column, columnId) => {
|
||||
return buildExpressionFunction<AggFunctionsMapping['aggCount']>('aggCount', {
|
||||
id: columnId,
|
||||
|
|
|
@ -689,4 +689,32 @@ describe('date_histogram', () => {
|
|||
expect(instance.find('[data-test-subj="lensDateHistogramValue"]').exists()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultLabel', () => {
|
||||
it('should not throw when the source field is not located', () => {
|
||||
expect(
|
||||
dateHistogramOperation.getDefaultLabel(
|
||||
{
|
||||
label: '',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'missing',
|
||||
params: { interval: 'auto' },
|
||||
},
|
||||
indexPattern1,
|
||||
{
|
||||
col1: {
|
||||
label: '',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'missing',
|
||||
params: { interval: 'auto' },
|
||||
},
|
||||
}
|
||||
)
|
||||
).toEqual('Missing field');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -28,7 +28,7 @@ import {
|
|||
search,
|
||||
} from '../../../../../../../src/plugins/data/public';
|
||||
import { buildExpressionFunction } from '../../../../../../../src/plugins/expressions/public';
|
||||
import { getInvalidFieldMessage } from './helpers';
|
||||
import { getInvalidFieldMessage, getSafeName } from './helpers';
|
||||
|
||||
const { isValidInterval } = search.aggs;
|
||||
const autoInterval = 'auto';
|
||||
|
@ -67,8 +67,7 @@ export const dateHistogramOperation: OperationDefinition<
|
|||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
indexPattern.getFieldByName(column.sourceField)!.displayName,
|
||||
getDefaultLabel: (column, indexPattern) => getSafeName(column.sourceField, indexPattern),
|
||||
buildColumn({ field }) {
|
||||
let interval = autoInterval;
|
||||
let timeZone: string | undefined;
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { createMockedIndexPattern } from '../../mocks';
|
||||
import { getInvalidFieldMessage } from './helpers';
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('getInvalidFieldMessage', () => {
|
||||
it('return an error if a field was removed', () => {
|
||||
const messages = getInvalidFieldMessage(
|
||||
{
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'count', // <= invalid
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
createMockedIndexPattern()
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages![0]).toEqual('Field bytes was not found');
|
||||
});
|
||||
|
||||
it('returns an error if a field is the wrong type', () => {
|
||||
const messages = getInvalidFieldMessage(
|
||||
{
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'avg', // <= invalid
|
||||
sourceField: 'timestamp',
|
||||
},
|
||||
createMockedIndexPattern()
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages![0]).toEqual('Field timestamp was not found');
|
||||
});
|
||||
|
||||
it('returns no message if all fields are matching', () => {
|
||||
const messages = getInvalidFieldMessage(
|
||||
{
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Foo',
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
createMockedIndexPattern()
|
||||
);
|
||||
expect(messages).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -62,3 +62,12 @@ export function getInvalidFieldMessage(
|
|||
]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getSafeName(name: string, indexPattern: IndexPattern): string {
|
||||
const field = indexPattern.getFieldByName(name);
|
||||
return field
|
||||
? field.displayName
|
||||
: i18n.translate('xpack.lens.indexPattern.missingFieldLabel', {
|
||||
defaultMessage: 'Missing field',
|
||||
});
|
||||
}
|
||||
|
|
|
@ -152,8 +152,9 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
|
|||
* return an updated column. If not implemented, the `id` function is used instead.
|
||||
*/
|
||||
onOtherColumnChanged?: (
|
||||
currentColumn: C,
|
||||
columns: Partial<Record<string, IndexPatternColumn>>
|
||||
layer: IndexPatternLayer,
|
||||
thisColumnId: string,
|
||||
changedColumnId: string
|
||||
) => C;
|
||||
/**
|
||||
* React component for operation specific settings shown in the popover editor
|
||||
|
@ -176,7 +177,7 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
|
|||
* but disable it from usage, this function returns the string describing
|
||||
* the status. Otherwise it returns undefined
|
||||
*/
|
||||
getDisabledStatus?: (indexPattern: IndexPattern) => string | undefined;
|
||||
getDisabledStatus?: (indexPattern: IndexPattern, layer: IndexPatternLayer) => string | undefined;
|
||||
/**
|
||||
* Validate that the operation has the right preconditions in the state. For example:
|
||||
*
|
||||
|
@ -314,9 +315,9 @@ interface FullReferenceOperationDefinition<C extends BaseIndexPatternColumn> {
|
|||
) => ReferenceBasedIndexPatternColumn & C;
|
||||
/**
|
||||
* Returns the meta data of the operation if applied. Undefined
|
||||
* if the field is not applicable.
|
||||
* if the operation can't be added with these fields.
|
||||
*/
|
||||
getPossibleOperation: () => OperationMetadata;
|
||||
getPossibleOperation: (indexPattern: IndexPattern) => OperationMetadata | undefined;
|
||||
/**
|
||||
* A chain of expression functions which will transform the table
|
||||
*/
|
||||
|
|
|
@ -311,13 +311,13 @@ describe('last_value', () => {
|
|||
it('should return disabledStatus if indexPattern does contain date field', () => {
|
||||
const indexPattern = createMockedIndexPattern();
|
||||
|
||||
expect(lastValueOperation.getDisabledStatus!(indexPattern)).toEqual(undefined);
|
||||
expect(lastValueOperation.getDisabledStatus!(indexPattern, layer)).toEqual(undefined);
|
||||
|
||||
const indexPatternWithoutTimeFieldName = {
|
||||
...indexPattern,
|
||||
timeFieldName: undefined,
|
||||
};
|
||||
expect(lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName)).toEqual(
|
||||
expect(lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName, layer)).toEqual(
|
||||
undefined
|
||||
);
|
||||
|
||||
|
@ -326,7 +326,10 @@ describe('last_value', () => {
|
|||
fields: indexPattern.fields.filter((f) => f.type !== 'date'),
|
||||
};
|
||||
|
||||
const disabledStatus = lastValueOperation.getDisabledStatus!(indexPatternWithoutTimefields);
|
||||
const disabledStatus = lastValueOperation.getDisabledStatus!(
|
||||
indexPatternWithoutTimefields,
|
||||
layer
|
||||
);
|
||||
expect(disabledStatus).toEqual(
|
||||
'This function requires the presence of a date field in your index'
|
||||
);
|
||||
|
|
|
@ -13,12 +13,14 @@ import { FieldBasedIndexPatternColumn } from './column_types';
|
|||
import { IndexPatternField, IndexPattern } from '../../types';
|
||||
import { updateColumnParam } from '../layer_helpers';
|
||||
import { DataType } from '../../../types';
|
||||
import { getInvalidFieldMessage } from './helpers';
|
||||
import { getInvalidFieldMessage, getSafeName } from './helpers';
|
||||
|
||||
function ofName(name: string) {
|
||||
return i18n.translate('xpack.lens.indexPattern.lastValueOf', {
|
||||
defaultMessage: 'Last value of {name}',
|
||||
values: { name },
|
||||
values: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -87,8 +89,7 @@ export const lastValueOperation: OperationDefinition<LastValueIndexPatternColumn
|
|||
displayName: i18n.translate('xpack.lens.indexPattern.lastValue', {
|
||||
defaultMessage: 'Last value',
|
||||
}),
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
indexPattern.getFieldByName(column.sourceField)!.displayName,
|
||||
getDefaultLabel: (column, indexPattern) => ofName(getSafeName(column.sourceField, indexPattern)),
|
||||
input: 'field',
|
||||
onFieldChange: (oldColumn, field) => {
|
||||
const newParams = { ...oldColumn.params };
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { buildExpressionFunction } from '../../../../../../../src/plugins/expressions/public';
|
||||
import { OperationDefinition } from './index';
|
||||
import { getInvalidFieldMessage } from './helpers';
|
||||
import { getInvalidFieldMessage, getSafeName } from './helpers';
|
||||
import {
|
||||
FormattedIndexPatternColumn,
|
||||
FieldBasedIndexPatternColumn,
|
||||
|
@ -45,11 +45,11 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
optionalTimeScaling?: boolean;
|
||||
}) {
|
||||
const labelLookup = (name: string, column?: BaseIndexPatternColumn) => {
|
||||
const rawLabel = ofName(name);
|
||||
const label = ofName(name);
|
||||
if (!optionalTimeScaling) {
|
||||
return rawLabel;
|
||||
return label;
|
||||
}
|
||||
return adjustTimeScaleLabelSuffix(rawLabel, undefined, column?.timeScale);
|
||||
return adjustTimeScaleLabelSuffix(label, undefined, column?.timeScale);
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -81,10 +81,12 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
(!newField.aggregationRestrictions || newField.aggregationRestrictions![type])
|
||||
);
|
||||
},
|
||||
onOtherColumnChanged: (column, otherColumns) =>
|
||||
optionalTimeScaling ? adjustTimeScaleOnOtherColumnChange(column, otherColumns) : column,
|
||||
onOtherColumnChanged: (layer, thisColumnId, changedColumnId) =>
|
||||
optionalTimeScaling
|
||||
? adjustTimeScaleOnOtherColumnChange(layer, thisColumnId, changedColumnId)
|
||||
: layer.columns[thisColumnId],
|
||||
getDefaultLabel: (column, indexPattern, columns) =>
|
||||
labelLookup(indexPattern.getFieldByName(column.sourceField)!.displayName, column),
|
||||
labelLookup(getSafeName(column.sourceField, indexPattern), column),
|
||||
buildColumn: ({ field, previousColumn }) => ({
|
||||
label: labelLookup(field.displayName, previousColumn),
|
||||
dataType: 'number',
|
||||
|
|
|
@ -98,7 +98,10 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field
|
|||
}
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
indexPattern.getFieldByName(column.sourceField)!.displayName,
|
||||
indexPattern.getFieldByName(column.sourceField)?.displayName ??
|
||||
i18n.translate('xpack.lens.indexPattern.missingFieldLabel', {
|
||||
defaultMessage: 'Missing field',
|
||||
}),
|
||||
buildColumn({ field }) {
|
||||
return {
|
||||
label: field.displayName,
|
||||
|
|
|
@ -18,23 +18,36 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public';
|
||||
import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public';
|
||||
import { IndexPatternColumn } from '../../../indexpattern';
|
||||
import { updateColumnParam, isReferenced } from '../../layer_helpers';
|
||||
import { DataType } from '../../../../types';
|
||||
import { OperationDefinition } from '../index';
|
||||
import { FieldBasedIndexPatternColumn } from '../column_types';
|
||||
import { ValuesRangeInput } from './values_range_input';
|
||||
import { getInvalidFieldMessage } from '../helpers';
|
||||
import type { IndexPatternLayer } from '../../../types';
|
||||
|
||||
function ofName(name: string) {
|
||||
function ofName(name?: string) {
|
||||
return i18n.translate('xpack.lens.indexPattern.termsOf', {
|
||||
defaultMessage: 'Top values of {name}',
|
||||
values: { name },
|
||||
values: {
|
||||
name:
|
||||
name ??
|
||||
i18n.translate('xpack.lens.indexPattern.missingFieldLabel', {
|
||||
defaultMessage: 'Missing field',
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isSortableByColumn(column: IndexPatternColumn) {
|
||||
return !column.isBucketed && column.operationType !== 'last_value';
|
||||
function isSortableByColumn(layer: IndexPatternLayer, columnId: string) {
|
||||
const column = layer.columns[columnId];
|
||||
return (
|
||||
column &&
|
||||
!column.isBucketed &&
|
||||
column.operationType !== 'last_value' &&
|
||||
!('references' in column) &&
|
||||
!isReferenced(layer, columnId)
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_SIZE = 3;
|
||||
|
@ -89,10 +102,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
|
|||
},
|
||||
buildColumn({ layer, field, indexPattern }) {
|
||||
const existingMetricColumn = Object.entries(layer.columns)
|
||||
.filter(
|
||||
([columnId, column]) =>
|
||||
column && !isReferenced(layer, columnId) && isSortableByColumn(column)
|
||||
)
|
||||
.filter(([columnId]) => isSortableByColumn(layer, columnId))
|
||||
.map(([id]) => id)[0];
|
||||
|
||||
const previousBucketsLength = Object.values(layer.columns).filter(
|
||||
|
@ -138,7 +148,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
|
|||
}).toAst();
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern) =>
|
||||
ofName(indexPattern.getFieldByName(column.sourceField)!.displayName),
|
||||
ofName(indexPattern.getFieldByName(column.sourceField)?.displayName),
|
||||
onFieldChange: (oldColumn, field) => {
|
||||
const newParams = { ...oldColumn.params };
|
||||
if ('format' in newParams && field.type !== 'number') {
|
||||
|
@ -152,11 +162,13 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
|
|||
params: newParams,
|
||||
};
|
||||
},
|
||||
onOtherColumnChanged: (currentColumn, columns) => {
|
||||
onOtherColumnChanged: (layer, thisColumnId, changedColumnId) => {
|
||||
const columns = layer.columns;
|
||||
const currentColumn = columns[thisColumnId] as TermsIndexPatternColumn;
|
||||
if (currentColumn.params.orderBy.type === 'column') {
|
||||
// check whether the column is still there and still a metric
|
||||
const columnSortedBy = columns[currentColumn.params.orderBy.columnId];
|
||||
if (!columnSortedBy || !isSortableByColumn(columnSortedBy)) {
|
||||
if (!columnSortedBy || !isSortableByColumn(layer, changedColumnId)) {
|
||||
return {
|
||||
...currentColumn,
|
||||
params: {
|
||||
|
@ -194,7 +206,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
|
|||
}
|
||||
|
||||
const orderOptions = Object.entries(layer.columns)
|
||||
.filter(([sortId, column]) => isSortableByColumn(column))
|
||||
.filter(([sortId]) => isSortableByColumn(layer, sortId))
|
||||
.map(([sortId, column]) => {
|
||||
return {
|
||||
value: toValue({ type: 'column', columnId: sortId }),
|
||||
|
|
|
@ -402,15 +402,25 @@ describe('terms', () => {
|
|||
},
|
||||
sourceField: 'category',
|
||||
};
|
||||
const updatedColumn = termsOperation.onOtherColumnChanged!(initialColumn, {
|
||||
col1: {
|
||||
label: 'Count',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'Records',
|
||||
operationType: 'count',
|
||||
const updatedColumn = termsOperation.onOtherColumnChanged!(
|
||||
{
|
||||
indexPatternId: '',
|
||||
columnOrder: [],
|
||||
columns: {
|
||||
col2: initialColumn,
|
||||
col1: {
|
||||
label: 'Count',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'Records',
|
||||
operationType: 'count',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
'col2',
|
||||
'col1'
|
||||
);
|
||||
|
||||
expect(updatedColumn).toBe(initialColumn);
|
||||
});
|
||||
|
||||
|
@ -429,18 +439,74 @@ describe('terms', () => {
|
|||
},
|
||||
sourceField: 'category',
|
||||
};
|
||||
const updatedColumn = termsOperation.onOtherColumnChanged!(initialColumn, {
|
||||
col1: {
|
||||
label: 'Last Value',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'bytes',
|
||||
operationType: 'last_value',
|
||||
params: {
|
||||
sortField: 'time',
|
||||
const updatedColumn = termsOperation.onOtherColumnChanged!(
|
||||
{
|
||||
columns: {
|
||||
col2: initialColumn,
|
||||
col1: {
|
||||
label: 'Last Value',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'bytes',
|
||||
operationType: 'last_value',
|
||||
params: {
|
||||
sortField: 'time',
|
||||
},
|
||||
},
|
||||
},
|
||||
columnOrder: [],
|
||||
indexPatternId: '',
|
||||
},
|
||||
});
|
||||
'col2',
|
||||
'col1'
|
||||
);
|
||||
expect(updatedColumn.params).toEqual(
|
||||
expect.objectContaining({
|
||||
orderBy: { type: 'alphabetical' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch to alphabetical ordering if metric is reference-based', () => {
|
||||
const initialColumn: TermsIndexPatternColumn = {
|
||||
label: 'Top value of category',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
// Private
|
||||
operationType: 'terms',
|
||||
params: {
|
||||
orderBy: { type: 'column', columnId: 'col1' },
|
||||
size: 3,
|
||||
orderDirection: 'asc',
|
||||
},
|
||||
sourceField: 'category',
|
||||
};
|
||||
const updatedColumn = termsOperation.onOtherColumnChanged!(
|
||||
{
|
||||
columns: {
|
||||
col2: initialColumn,
|
||||
col1: {
|
||||
label: 'Cumulative sum',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['referenced'],
|
||||
},
|
||||
referenced: {
|
||||
label: '',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
},
|
||||
},
|
||||
columnOrder: [],
|
||||
indexPatternId: '',
|
||||
},
|
||||
'col2',
|
||||
'col1'
|
||||
);
|
||||
expect(updatedColumn.params).toEqual(
|
||||
expect.objectContaining({
|
||||
orderBy: { type: 'alphabetical' },
|
||||
|
@ -451,20 +517,27 @@ describe('terms', () => {
|
|||
it('should switch to alphabetical ordering if there are no columns to order by', () => {
|
||||
const termsColumn = termsOperation.onOtherColumnChanged!(
|
||||
{
|
||||
label: 'Top value of category',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
columns: {
|
||||
col2: {
|
||||
label: 'Top value of category',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
// Private
|
||||
operationType: 'terms',
|
||||
params: {
|
||||
orderBy: { type: 'column', columnId: 'col1' },
|
||||
size: 3,
|
||||
orderDirection: 'asc',
|
||||
// Private
|
||||
operationType: 'terms',
|
||||
params: {
|
||||
orderBy: { type: 'column', columnId: 'col1' },
|
||||
size: 3,
|
||||
orderDirection: 'asc',
|
||||
},
|
||||
sourceField: 'category',
|
||||
},
|
||||
},
|
||||
sourceField: 'category',
|
||||
columnOrder: [],
|
||||
indexPatternId: '',
|
||||
},
|
||||
{}
|
||||
'col2',
|
||||
'col1'
|
||||
);
|
||||
expect(termsColumn.params).toEqual(
|
||||
expect.objectContaining({
|
||||
|
@ -476,33 +549,39 @@ describe('terms', () => {
|
|||
it('should switch to alphabetical ordering if the order column is not a metric anymore', () => {
|
||||
const termsColumn = termsOperation.onOtherColumnChanged!(
|
||||
{
|
||||
label: 'Top value of category',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
columns: {
|
||||
col2: {
|
||||
label: 'Top value of category',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
// Private
|
||||
operationType: 'terms',
|
||||
params: {
|
||||
orderBy: { type: 'column', columnId: 'col1' },
|
||||
size: 3,
|
||||
orderDirection: 'asc',
|
||||
},
|
||||
sourceField: 'category',
|
||||
},
|
||||
{
|
||||
col1: {
|
||||
label: 'Value of timestamp',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
|
||||
// Private
|
||||
operationType: 'date_histogram',
|
||||
params: {
|
||||
interval: 'w',
|
||||
// Private
|
||||
operationType: 'terms',
|
||||
params: {
|
||||
orderBy: { type: 'column', columnId: 'col1' },
|
||||
size: 3,
|
||||
orderDirection: 'asc',
|
||||
},
|
||||
sourceField: 'category',
|
||||
},
|
||||
col1: {
|
||||
label: 'Value of timestamp',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
|
||||
// Private
|
||||
operationType: 'date_histogram',
|
||||
params: {
|
||||
interval: 'w',
|
||||
},
|
||||
sourceField: 'timestamp',
|
||||
},
|
||||
sourceField: 'timestamp',
|
||||
},
|
||||
}
|
||||
columnOrder: [],
|
||||
indexPatternId: '',
|
||||
},
|
||||
'col2',
|
||||
'col1'
|
||||
);
|
||||
expect(termsColumn.params).toEqual(
|
||||
expect.objectContaining({
|
||||
|
|
|
@ -12,6 +12,7 @@ export {
|
|||
IndexPatternColumn,
|
||||
FieldBasedIndexPatternColumn,
|
||||
IncompleteColumn,
|
||||
RequiredReference,
|
||||
} from './definitions';
|
||||
|
||||
export { createMockedReferenceOperation } from './mocks';
|
||||
|
|
|
@ -190,6 +190,44 @@ describe('state_helpers', () => {
|
|||
).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2'] }));
|
||||
});
|
||||
|
||||
it('should insert a metric after buckets, but before references', () => {
|
||||
const layer: IndexPatternLayer = {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Date histogram of timestamp',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
|
||||
// Private
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'timestamp',
|
||||
params: {
|
||||
interval: 'h',
|
||||
},
|
||||
},
|
||||
col3: {
|
||||
label: 'Reference',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
operationType: 'cumulative_sum',
|
||||
references: ['col2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(
|
||||
insertNewColumn({
|
||||
layer,
|
||||
indexPattern,
|
||||
columnId: 'col2',
|
||||
op: 'count',
|
||||
field: documentField,
|
||||
})
|
||||
).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2', 'col3'] }));
|
||||
});
|
||||
|
||||
it('should insert new buckets at the end of previous buckets', () => {
|
||||
const layer: IndexPatternLayer = {
|
||||
indexPatternId: '1',
|
||||
|
@ -782,18 +820,83 @@ describe('state_helpers', () => {
|
|||
field: indexPattern.fields[2], // bytes field
|
||||
});
|
||||
|
||||
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, {
|
||||
col1: termsColumn,
|
||||
col2: expect.objectContaining({
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(
|
||||
{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: termsColumn,
|
||||
col2: expect.objectContaining({
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'bytes',
|
||||
operationType: 'avg',
|
||||
}),
|
||||
},
|
||||
incompleteColumns: {},
|
||||
},
|
||||
'col1',
|
||||
'col2'
|
||||
);
|
||||
});
|
||||
|
||||
// Private
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
}),
|
||||
it('should execute adjustments for other columns when creating a reference', () => {
|
||||
const termsColumn: TermsIndexPatternColumn = {
|
||||
label: 'Top values of source',
|
||||
dataType: 'string',
|
||||
isBucketed: true,
|
||||
|
||||
// Private
|
||||
operationType: 'terms',
|
||||
sourceField: 'source',
|
||||
params: {
|
||||
orderBy: { type: 'column', columnId: 'willBeReference' },
|
||||
orderDirection: 'desc',
|
||||
size: 5,
|
||||
},
|
||||
};
|
||||
|
||||
replaceColumn({
|
||||
layer: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'willBeReference'],
|
||||
columns: {
|
||||
col1: termsColumn,
|
||||
willBeReference: {
|
||||
label: 'Count',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'Records',
|
||||
operationType: 'count',
|
||||
},
|
||||
},
|
||||
},
|
||||
indexPattern,
|
||||
columnId: 'willBeReference',
|
||||
op: 'cumulative_sum',
|
||||
});
|
||||
|
||||
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(
|
||||
{
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'willBeReference'],
|
||||
columns: {
|
||||
col1: {
|
||||
...termsColumn,
|
||||
params: { orderBy: { type: 'alphabetical' }, orderDirection: 'asc', size: 5 },
|
||||
},
|
||||
willBeReference: expect.objectContaining({
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'cumulative_sum',
|
||||
}),
|
||||
},
|
||||
incompleteColumns: {},
|
||||
},
|
||||
'col1',
|
||||
'willBeReference'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not wrap the previous operation when switching to reference', () => {
|
||||
|
@ -963,7 +1066,7 @@ describe('state_helpers', () => {
|
|||
isTransferable: jest.fn(),
|
||||
toExpression: jest.fn().mockReturnValue([]),
|
||||
getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }),
|
||||
getDefaultLabel: () => 'Test reference',
|
||||
getDefaultLabel: jest.fn().mockReturnValue('Test reference'),
|
||||
};
|
||||
|
||||
const layer: IndexPatternLayer = {
|
||||
|
@ -1081,6 +1184,7 @@ describe('state_helpers', () => {
|
|||
},
|
||||
},
|
||||
columnId: 'col1',
|
||||
indexPattern,
|
||||
})
|
||||
).toEqual({
|
||||
indexPatternId: '1',
|
||||
|
@ -1126,6 +1230,7 @@ describe('state_helpers', () => {
|
|||
},
|
||||
},
|
||||
columnId: 'col2',
|
||||
indexPattern,
|
||||
})
|
||||
).toEqual({
|
||||
indexPatternId: '1',
|
||||
|
@ -1176,11 +1281,14 @@ describe('state_helpers', () => {
|
|||
},
|
||||
},
|
||||
columnId: 'col2',
|
||||
indexPattern,
|
||||
});
|
||||
|
||||
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, {
|
||||
col1: termsColumn,
|
||||
});
|
||||
expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(
|
||||
{ indexPatternId: '1', columnOrder: ['col1', 'col2'], columns: { col1: termsColumn } },
|
||||
'col1',
|
||||
'col2'
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete the column and all of its references', () => {
|
||||
|
@ -1207,11 +1315,57 @@ describe('state_helpers', () => {
|
|||
},
|
||||
},
|
||||
};
|
||||
expect(deleteColumn({ layer, columnId: 'col2' })).toEqual(
|
||||
expect(deleteColumn({ layer, columnId: 'col2', indexPattern })).toEqual(
|
||||
expect.objectContaining({ columnOrder: [], columns: {} })
|
||||
);
|
||||
});
|
||||
|
||||
it('should update the labels when deleting columns', () => {
|
||||
const layer: IndexPatternLayer = {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Count',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
},
|
||||
col2: {
|
||||
label: 'Changed label',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// @ts-expect-error not a valid type
|
||||
operationType: 'testReference',
|
||||
references: ['col1'],
|
||||
},
|
||||
},
|
||||
};
|
||||
deleteColumn({ layer, columnId: 'col1', indexPattern });
|
||||
expect(operationDefinitionMap.testReference.getDefaultLabel).toHaveBeenCalledWith(
|
||||
{
|
||||
label: 'Changed label',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'testReference',
|
||||
references: ['col1'],
|
||||
},
|
||||
indexPattern,
|
||||
{
|
||||
col2: {
|
||||
label: 'Default label',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'testReference',
|
||||
references: ['col1'],
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should recursively delete references', () => {
|
||||
const layer: IndexPatternLayer = {
|
||||
indexPatternId: '1',
|
||||
|
@ -1245,7 +1399,7 @@ describe('state_helpers', () => {
|
|||
},
|
||||
},
|
||||
};
|
||||
expect(deleteColumn({ layer, columnId: 'col3' })).toEqual(
|
||||
expect(deleteColumn({ layer, columnId: 'col3', indexPattern })).toEqual(
|
||||
expect.objectContaining({ columnOrder: [], columns: {} })
|
||||
);
|
||||
});
|
||||
|
@ -1680,7 +1834,22 @@ describe('state_helpers', () => {
|
|||
});
|
||||
|
||||
describe('getErrorMessages', () => {
|
||||
it('should collect errors from the operation definitions', () => {
|
||||
it('should collect errors from metric-type operation definitions', () => {
|
||||
const mock = jest.fn().mockReturnValue(['error 1']);
|
||||
operationDefinitionMap.avg.getErrorMessage = mock;
|
||||
const errors = getErrorMessages({
|
||||
indexPatternId: '1',
|
||||
columnOrder: [],
|
||||
columns: {
|
||||
// @ts-expect-error invalid column
|
||||
col1: { operationType: 'avg' },
|
||||
},
|
||||
});
|
||||
expect(mock).toHaveBeenCalled();
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should collect errors from reference-type operation definitions', () => {
|
||||
const mock = jest.fn().mockReturnValue(['error 1']);
|
||||
operationDefinitionMap.testReference.getErrorMessage = mock;
|
||||
const errors = getErrorMessages({
|
||||
|
@ -1695,49 +1864,5 @@ describe('state_helpers', () => {
|
|||
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,7 +5,6 @@
|
|||
*/
|
||||
|
||||
import _, { partition } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
operationDefinitionMap,
|
||||
operationDefinitions,
|
||||
|
@ -61,9 +60,15 @@ export function insertNewColumn({
|
|||
const possibleOperation = operationDefinition.getPossibleOperation();
|
||||
const isBucketed = Boolean(possibleOperation.isBucketed);
|
||||
if (isBucketed) {
|
||||
return addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId);
|
||||
return updateDefaultLabels(
|
||||
addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId),
|
||||
indexPattern
|
||||
);
|
||||
} else {
|
||||
return addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId);
|
||||
return updateDefaultLabels(
|
||||
addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId),
|
||||
indexPattern
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,7 +82,7 @@ export function insertNewColumn({
|
|||
// access to the operationSupportMatrix, we should validate the metadata against
|
||||
// the possible fields
|
||||
const validOperations = Object.values(operationDefinitionMap).filter(({ type }) =>
|
||||
isOperationAllowedAsReference({ validation, operationType: type })
|
||||
isOperationAllowedAsReference({ validation, operationType: type, indexPattern })
|
||||
);
|
||||
|
||||
if (!validOperations.length) {
|
||||
|
@ -122,29 +127,23 @@ export function insertNewColumn({
|
|||
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
|
||||
const possibleOperation = operationDefinition.getPossibleOperation(indexPattern);
|
||||
if (!possibleOperation) {
|
||||
throw new Error(
|
||||
`Can't create operation ${op} because it's incompatible with the index pattern`
|
||||
);
|
||||
}
|
||||
const isBucketed = Boolean(possibleOperation.isBucketed);
|
||||
|
||||
const addOperationFn = isBucketed ? addBucket : addMetric;
|
||||
return updateDefaultLabels(
|
||||
addOperationFn(
|
||||
tempLayer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, referenceIds }),
|
||||
columnId
|
||||
),
|
||||
indexPattern
|
||||
);
|
||||
}
|
||||
|
||||
const invalidFieldName = (layer.incompleteColumns ?? {})[columnId]?.sourceField;
|
||||
|
@ -159,16 +158,22 @@ export function insertNewColumn({
|
|||
}
|
||||
const isBucketed = Boolean(possibleOperation.isBucketed);
|
||||
if (isBucketed) {
|
||||
return addBucket(
|
||||
layer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer, field: invalidField }),
|
||||
columnId
|
||||
return updateDefaultLabels(
|
||||
addBucket(
|
||||
layer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer, field: invalidField }),
|
||||
columnId
|
||||
),
|
||||
indexPattern
|
||||
);
|
||||
} else {
|
||||
return addMetric(
|
||||
layer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer, field: invalidField }),
|
||||
columnId
|
||||
return updateDefaultLabels(
|
||||
addMetric(
|
||||
layer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer, field: invalidField }),
|
||||
columnId
|
||||
),
|
||||
indexPattern
|
||||
);
|
||||
}
|
||||
} else if (!field) {
|
||||
|
@ -193,19 +198,15 @@ export function insertNewColumn({
|
|||
};
|
||||
}
|
||||
const isBucketed = Boolean(possibleOperation.isBucketed);
|
||||
if (isBucketed) {
|
||||
return addBucket(
|
||||
const addOperationFn = isBucketed ? addBucket : addMetric;
|
||||
return updateDefaultLabels(
|
||||
addOperationFn(
|
||||
layer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer, field }),
|
||||
columnId
|
||||
);
|
||||
} else {
|
||||
return addMetric(
|
||||
layer,
|
||||
operationDefinition.buildColumn({ ...baseOptions, layer, field }),
|
||||
columnId
|
||||
);
|
||||
}
|
||||
),
|
||||
indexPattern
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceColumn({
|
||||
|
@ -241,39 +242,50 @@ export function replaceColumn({
|
|||
|
||||
if (previousDefinition.input === 'fullReference') {
|
||||
(previousColumn as ReferenceBasedIndexPatternColumn).references.forEach((id: string) => {
|
||||
tempLayer = deleteColumn({ layer: tempLayer, columnId: id });
|
||||
tempLayer = deleteColumn({ layer: tempLayer, columnId: id, indexPattern });
|
||||
});
|
||||
}
|
||||
|
||||
tempLayer = resetIncomplete(tempLayer, columnId);
|
||||
|
||||
if (operationDefinition.input === 'fullReference') {
|
||||
const referenceIds = operationDefinition.requiredReferences.map(() => generateId());
|
||||
|
||||
const newColumns = {
|
||||
...tempLayer.columns,
|
||||
[columnId]: operationDefinition.buildColumn({
|
||||
...baseOptions,
|
||||
layer: tempLayer,
|
||||
referenceIds,
|
||||
previousColumn,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
const newLayer = {
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
|
||||
columns: newColumns,
|
||||
columns: {
|
||||
...tempLayer.columns,
|
||||
[columnId]: operationDefinition.buildColumn({
|
||||
...baseOptions,
|
||||
layer: tempLayer,
|
||||
referenceIds,
|
||||
previousColumn,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return updateDefaultLabels(
|
||||
{
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder(newLayer),
|
||||
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
|
||||
},
|
||||
indexPattern
|
||||
);
|
||||
}
|
||||
|
||||
if (operationDefinition.input === 'none') {
|
||||
let newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer });
|
||||
newColumn = adjustLabel(newColumn, previousColumn);
|
||||
|
||||
const newColumns = { ...tempLayer.columns, [columnId]: newColumn };
|
||||
return {
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
|
||||
columns: adjustColumnReferencesForChangedColumn(newColumns, columnId),
|
||||
};
|
||||
const newLayer = { ...tempLayer, columns: { ...tempLayer.columns, [columnId]: newColumn } };
|
||||
return updateDefaultLabels(
|
||||
{
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder(newLayer),
|
||||
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
|
||||
},
|
||||
indexPattern
|
||||
);
|
||||
}
|
||||
|
||||
if (!field) {
|
||||
|
@ -289,12 +301,15 @@ export function replaceColumn({
|
|||
let newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, field });
|
||||
newColumn = adjustLabel(newColumn, previousColumn);
|
||||
|
||||
const newColumns = { ...tempLayer.columns, [columnId]: newColumn };
|
||||
return {
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }),
|
||||
columns: adjustColumnReferencesForChangedColumn(newColumns, columnId),
|
||||
};
|
||||
const newLayer = { ...tempLayer, columns: { ...tempLayer.columns, [columnId]: newColumn } };
|
||||
return updateDefaultLabels(
|
||||
{
|
||||
...tempLayer,
|
||||
columnOrder: getColumnOrder(newLayer),
|
||||
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
|
||||
},
|
||||
indexPattern
|
||||
);
|
||||
} else if (
|
||||
operationDefinition.input === 'field' &&
|
||||
field &&
|
||||
|
@ -304,12 +319,20 @@ export function replaceColumn({
|
|||
// Same operation, new field
|
||||
const newColumn = operationDefinition.onFieldChange(previousColumn, field);
|
||||
|
||||
const newColumns = { ...layer.columns, [columnId]: adjustLabel(newColumn, previousColumn) };
|
||||
return {
|
||||
...layer,
|
||||
columnOrder: getColumnOrder({ ...layer, columns: newColumns }),
|
||||
columns: adjustColumnReferencesForChangedColumn(newColumns, columnId),
|
||||
};
|
||||
if (previousColumn.customLabel) {
|
||||
newColumn.customLabel = true;
|
||||
newColumn.label = previousColumn.label;
|
||||
}
|
||||
|
||||
const newLayer = { ...layer, columns: { ...layer.columns, [columnId]: newColumn } };
|
||||
return updateDefaultLabels(
|
||||
{
|
||||
...resetIncomplete(layer, columnId),
|
||||
columnOrder: getColumnOrder(newLayer),
|
||||
columns: adjustColumnReferencesForChangedColumn(newLayer, columnId),
|
||||
},
|
||||
indexPattern
|
||||
);
|
||||
} else {
|
||||
throw new Error('nothing changed');
|
||||
}
|
||||
|
@ -370,7 +393,6 @@ function addMetric(
|
|||
...layer.columns,
|
||||
[addedColumnId]: column,
|
||||
},
|
||||
columnOrder: [...layer.columnOrder, addedColumnId],
|
||||
};
|
||||
return { ...tempLayer, columnOrder: getColumnOrder(tempLayer) };
|
||||
}
|
||||
|
@ -409,17 +431,18 @@ export function updateColumnParam<C extends IndexPatternColumn>({
|
|||
};
|
||||
}
|
||||
|
||||
function adjustColumnReferencesForChangedColumn(
|
||||
columns: Record<string, IndexPatternColumn>,
|
||||
columnId: string
|
||||
) {
|
||||
const newColumns = { ...columns };
|
||||
function adjustColumnReferencesForChangedColumn(layer: IndexPatternLayer, changedColumnId: string) {
|
||||
const newColumns = { ...layer.columns };
|
||||
Object.keys(newColumns).forEach((currentColumnId) => {
|
||||
if (currentColumnId !== columnId) {
|
||||
if (currentColumnId !== changedColumnId) {
|
||||
const currentColumn = newColumns[currentColumnId];
|
||||
const operationDefinition = operationDefinitionMap[currentColumn.operationType];
|
||||
newColumns[currentColumnId] = operationDefinition.onOtherColumnChanged
|
||||
? operationDefinition.onOtherColumnChanged(currentColumn, newColumns)
|
||||
? operationDefinition.onOtherColumnChanged(
|
||||
{ ...layer, columns: newColumns },
|
||||
currentColumnId,
|
||||
changedColumnId
|
||||
)
|
||||
: currentColumn;
|
||||
}
|
||||
});
|
||||
|
@ -429,9 +452,11 @@ function adjustColumnReferencesForChangedColumn(
|
|||
export function deleteColumn({
|
||||
layer,
|
||||
columnId,
|
||||
indexPattern,
|
||||
}: {
|
||||
layer: IndexPatternLayer;
|
||||
columnId: string;
|
||||
indexPattern: IndexPattern;
|
||||
}): IndexPatternLayer {
|
||||
const column = layer.columns[columnId];
|
||||
if (!column) {
|
||||
|
@ -451,17 +476,27 @@ export function deleteColumn({
|
|||
|
||||
let newLayer = {
|
||||
...layer,
|
||||
columns: adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId),
|
||||
columns: adjustColumnReferencesForChangedColumn(
|
||||
{ ...layer, columns: hypotheticalColumns },
|
||||
columnId
|
||||
),
|
||||
};
|
||||
|
||||
extraDeletions.forEach((id) => {
|
||||
newLayer = deleteColumn({ layer: newLayer, columnId: id });
|
||||
newLayer = deleteColumn({ layer: newLayer, columnId: id, indexPattern });
|
||||
});
|
||||
|
||||
const newIncomplete = { ...(newLayer.incompleteColumns || {}) };
|
||||
delete newIncomplete[columnId];
|
||||
|
||||
return { ...newLayer, columnOrder: getColumnOrder(newLayer), incompleteColumns: newIncomplete };
|
||||
return updateDefaultLabels(
|
||||
{
|
||||
...newLayer,
|
||||
columnOrder: getColumnOrder(newLayer),
|
||||
incompleteColumns: newIncomplete,
|
||||
},
|
||||
indexPattern
|
||||
);
|
||||
}
|
||||
|
||||
// Derives column order from column object, respects existing columnOrder
|
||||
|
@ -482,7 +517,7 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] {
|
|||
|
||||
const [direct, referenceBased] = _.partition(
|
||||
entries,
|
||||
([id, col]) => operationDefinitionMap[col.operationType].input !== 'fullReference'
|
||||
([, 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]) => {
|
||||
|
@ -503,7 +538,7 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] {
|
|||
}
|
||||
|
||||
// Splits existing columnOrder into the three categories
|
||||
function getExistingColumnGroups(layer: IndexPatternLayer): [string[], string[], string[]] {
|
||||
export function getExistingColumnGroups(layer: IndexPatternLayer): [string[], string[], string[]] {
|
||||
const [direct, referenced] = partition(
|
||||
layer.columnOrder,
|
||||
(columnId) => layer.columns[columnId] && !('references' in layer.columns[columnId])
|
||||
|
@ -553,44 +588,9 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined
|
|||
|
||||
Object.entries(layer.columns).forEach(([columnId, column]) => {
|
||||
const def = operationDefinitionMap[column.operationType];
|
||||
if (def.input === 'fullReference' && def.getErrorMessage) {
|
||||
if (def.getErrorMessage) {
|
||||
errors.push(...(def.getErrorMessage(layer, columnId) ?? []));
|
||||
}
|
||||
|
||||
if ('references' in column) {
|
||||
column.references.forEach((referenceId, index) => {
|
||||
if (!layer.columns[referenceId]) {
|
||||
errors.push(
|
||||
i18n.translate('xpack.lens.indexPattern.missingReferenceError', {
|
||||
defaultMessage: 'Dimension {dimensionLabel} is incomplete',
|
||||
values: {
|
||||
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: {
|
||||
dimensionLabel: column.label,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors.length ? errors : undefined;
|
||||
|
@ -603,30 +603,15 @@ export function isReferenced(layer: IndexPatternLayer, columnId: string): boolea
|
|||
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({
|
||||
export function isOperationAllowedAsReference({
|
||||
operationType,
|
||||
validation,
|
||||
field,
|
||||
indexPattern,
|
||||
}: {
|
||||
operationType: OperationType;
|
||||
validation: RequiredReference;
|
||||
indexPattern: IndexPattern;
|
||||
field?: IndexPatternField;
|
||||
}): boolean {
|
||||
const operationDefinition = operationDefinitionMap[operationType];
|
||||
|
@ -635,9 +620,12 @@ function isOperationAllowedAsReference({
|
|||
if (field && operationDefinition.input === 'field') {
|
||||
const metadata = operationDefinition.getPossibleOperationForField(field);
|
||||
hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!);
|
||||
} else if (operationDefinition.input !== 'field') {
|
||||
} else if (operationDefinition.input === 'none') {
|
||||
const metadata = operationDefinition.getPossibleOperation();
|
||||
hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!);
|
||||
} else if (operationDefinition.input === 'fullReference') {
|
||||
const metadata = operationDefinition.getPossibleOperation(indexPattern);
|
||||
hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!);
|
||||
} else {
|
||||
// TODO: How can we validate the metadata without a specific field?
|
||||
}
|
||||
|
@ -648,6 +636,29 @@ function isOperationAllowedAsReference({
|
|||
);
|
||||
}
|
||||
|
||||
// Labels need to be updated when columns are added because reference-based column labels
|
||||
// are sometimes copied into the parents
|
||||
function updateDefaultLabels(
|
||||
layer: IndexPatternLayer,
|
||||
indexPattern: IndexPattern
|
||||
): IndexPatternLayer {
|
||||
const copiedColumns = { ...layer.columns };
|
||||
layer.columnOrder.forEach((id) => {
|
||||
const col = copiedColumns[id];
|
||||
if (!col.customLabel) {
|
||||
copiedColumns[id] = {
|
||||
...col,
|
||||
label: operationDefinitionMap[col.operationType].getDefaultLabel(
|
||||
col,
|
||||
indexPattern,
|
||||
copiedColumns
|
||||
),
|
||||
};
|
||||
}
|
||||
});
|
||||
return { ...layer, columns: copiedColumns };
|
||||
}
|
||||
|
||||
export function resetIncomplete(layer: IndexPatternLayer, columnId: string): IndexPatternLayer {
|
||||
const incompleteColumns = { ...(layer.incompleteColumns ?? {}) };
|
||||
delete incompleteColumns[columnId];
|
||||
|
|
|
@ -167,10 +167,13 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) {
|
|||
operationDefinition.getPossibleOperation()
|
||||
);
|
||||
} else if (operationDefinition.input === 'fullReference') {
|
||||
addToMap(
|
||||
{ type: 'fullReference', operationType: operationDefinition.type },
|
||||
operationDefinition.getPossibleOperation()
|
||||
);
|
||||
const validOperation = operationDefinition.getPossibleOperation(indexPattern);
|
||||
if (validOperation) {
|
||||
addToMap(
|
||||
{ type: 'fullReference', operationType: operationDefinition.type },
|
||||
validOperation
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -4,8 +4,9 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { TimeScaleUnit } from '../time_scale';
|
||||
import { IndexPatternColumn } from './definitions';
|
||||
import type { IndexPatternLayer } from '../types';
|
||||
import type { TimeScaleUnit } from '../time_scale';
|
||||
import type { IndexPatternColumn } from './definitions';
|
||||
import { adjustTimeScaleLabelSuffix, adjustTimeScaleOnOtherColumnChange } from './time_scale_utils';
|
||||
|
||||
export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit;
|
||||
|
@ -48,45 +49,71 @@ describe('time scale utils', () => {
|
|||
isBucketed: false,
|
||||
timeScale: 's',
|
||||
};
|
||||
const baseLayer: IndexPatternLayer = {
|
||||
columns: { col1: baseColumn },
|
||||
columnOrder: [],
|
||||
indexPatternId: '',
|
||||
};
|
||||
it('should keep column if there is no time scale', () => {
|
||||
const column = { ...baseColumn, timeScale: undefined };
|
||||
expect(adjustTimeScaleOnOtherColumnChange(column, { col1: column })).toBe(column);
|
||||
expect(
|
||||
adjustTimeScaleOnOtherColumnChange(
|
||||
{ ...baseLayer, columns: { col1: column } },
|
||||
'col1',
|
||||
'col2'
|
||||
)
|
||||
).toBe(column);
|
||||
});
|
||||
|
||||
it('should keep time scale if there is a date histogram', () => {
|
||||
expect(
|
||||
adjustTimeScaleOnOtherColumnChange(baseColumn, {
|
||||
col1: baseColumn,
|
||||
col2: {
|
||||
operationType: 'date_histogram',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: '',
|
||||
adjustTimeScaleOnOtherColumnChange(
|
||||
{
|
||||
...baseLayer,
|
||||
columns: {
|
||||
col1: baseColumn,
|
||||
col2: {
|
||||
operationType: 'date_histogram',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: '',
|
||||
sourceField: 'date',
|
||||
params: { interval: 'auto' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
'col1',
|
||||
'col2'
|
||||
)
|
||||
).toBe(baseColumn);
|
||||
});
|
||||
|
||||
it('should remove time scale if there is no date histogram', () => {
|
||||
expect(adjustTimeScaleOnOtherColumnChange(baseColumn, { col1: baseColumn })).toHaveProperty(
|
||||
expect(adjustTimeScaleOnOtherColumnChange(baseLayer, 'col1', 'col2')).toHaveProperty(
|
||||
'timeScale',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove suffix from label', () => {
|
||||
expect(adjustTimeScaleOnOtherColumnChange(baseColumn, { col1: baseColumn })).toHaveProperty(
|
||||
'label',
|
||||
'Count of records'
|
||||
);
|
||||
expect(
|
||||
adjustTimeScaleOnOtherColumnChange(
|
||||
{ ...baseLayer, columns: { col1: baseColumn } },
|
||||
'col1',
|
||||
'col2'
|
||||
)
|
||||
).toHaveProperty('label', 'Count of records');
|
||||
});
|
||||
|
||||
it('should keep custom label', () => {
|
||||
const column = { ...baseColumn, label: 'abc', customLabel: true };
|
||||
expect(adjustTimeScaleOnOtherColumnChange(column, { col1: column })).toHaveProperty(
|
||||
'label',
|
||||
'abc'
|
||||
);
|
||||
expect(
|
||||
adjustTimeScaleOnOtherColumnChange(
|
||||
{ ...baseLayer, columns: { col1: column } },
|
||||
'col1',
|
||||
'col2'
|
||||
)
|
||||
).toHaveProperty('label', 'abc');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
*/
|
||||
|
||||
import { unitSuffixesLong } from '../suffix_formatter';
|
||||
import { TimeScaleUnit } from '../time_scale';
|
||||
import { BaseIndexPatternColumn } from './definitions/column_types';
|
||||
import type { TimeScaleUnit } from '../time_scale';
|
||||
import type { IndexPatternLayer } from '../types';
|
||||
import type { IndexPatternColumn } from './definitions';
|
||||
|
||||
export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit;
|
||||
|
||||
|
@ -30,10 +31,13 @@ export function adjustTimeScaleLabelSuffix(
|
|||
return `${cleanedLabel} ${unitSuffixesLong[newTimeScale]}`;
|
||||
}
|
||||
|
||||
export function adjustTimeScaleOnOtherColumnChange<T extends BaseIndexPatternColumn>(
|
||||
column: T,
|
||||
columns: Partial<Record<string, BaseIndexPatternColumn>>
|
||||
) {
|
||||
export function adjustTimeScaleOnOtherColumnChange<T extends IndexPatternColumn>(
|
||||
layer: IndexPatternLayer,
|
||||
thisColumnId: string,
|
||||
changedColumnId: string
|
||||
): T {
|
||||
const columns = layer.columns;
|
||||
const column = columns[thisColumnId] as T;
|
||||
if (!column.timeScale) {
|
||||
return column;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { DataType } from '../types';
|
||||
import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from './types';
|
||||
import { IndexPattern, IndexPatternLayer } from './types';
|
||||
import { DraggedField } from './indexpattern';
|
||||
import {
|
||||
BaseIndexPatternColumn,
|
||||
|
@ -44,29 +44,6 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg
|
|||
);
|
||||
}
|
||||
|
||||
export function hasInvalidColumns(state: IndexPatternPrivateState) {
|
||||
return getInvalidLayers(state).length > 0;
|
||||
}
|
||||
|
||||
export function getInvalidLayers(state: IndexPatternPrivateState) {
|
||||
return Object.values(state.layers).filter((layer) => {
|
||||
return layer.columnOrder.some((columnId) =>
|
||||
isColumnInvalid(layer, columnId, state.indexPatterns[layer.indexPatternId])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getInvalidColumnsForLayer(
|
||||
layers: IndexPatternLayer[],
|
||||
indexPatternMap: Record<string, IndexPattern>
|
||||
) {
|
||||
return layers.map((layer) => {
|
||||
return layer.columnOrder.filter((columnId) =>
|
||||
isColumnInvalid(layer, columnId, indexPatternMap[layer.indexPatternId])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isColumnInvalid(
|
||||
layer: IndexPatternLayer,
|
||||
columnId: string,
|
||||
|
|
|
@ -326,6 +326,81 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
expect(await PageObjects.lens.getDatatableCellText(0, 1)).to.eql('6,011.351');
|
||||
});
|
||||
|
||||
it('should create a valid XY chart with references', async () => {
|
||||
await PageObjects.visualize.navigateToNewVisualization();
|
||||
await PageObjects.visualize.clickVisType('lens');
|
||||
await PageObjects.lens.goToTimeRange();
|
||||
|
||||
await PageObjects.lens.configureDimension({
|
||||
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
|
||||
operation: 'date_histogram',
|
||||
field: '@timestamp',
|
||||
});
|
||||
await PageObjects.lens.configureDimension({
|
||||
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
|
||||
operation: 'moving_average',
|
||||
keepOpen: true,
|
||||
});
|
||||
await PageObjects.lens.configureReference({
|
||||
operation: 'sum',
|
||||
field: 'bytes',
|
||||
});
|
||||
await PageObjects.lens.closeDimensionEditor();
|
||||
|
||||
await PageObjects.lens.configureDimension({
|
||||
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
|
||||
operation: 'cumulative_sum',
|
||||
keepOpen: true,
|
||||
});
|
||||
await PageObjects.lens.configureReference({
|
||||
field: 'Records',
|
||||
});
|
||||
await PageObjects.lens.closeDimensionEditor();
|
||||
|
||||
// Two Y axes that are both valid
|
||||
expect(await find.allByCssSelector('.echLegendItem')).to.have.length(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* The edge cases are:
|
||||
*
|
||||
* 1. Showing errors when creating a partial configuration
|
||||
* 2. Being able to drag in a new field while in partial config
|
||||
* 3. Being able to switch charts while in partial config
|
||||
*/
|
||||
it('should handle edge cases in reference-based operations', async () => {
|
||||
await PageObjects.visualize.navigateToNewVisualization();
|
||||
await PageObjects.visualize.clickVisType('lens');
|
||||
await PageObjects.lens.goToTimeRange();
|
||||
|
||||
await PageObjects.lens.configureDimension({
|
||||
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
|
||||
operation: 'date_histogram',
|
||||
field: '@timestamp',
|
||||
});
|
||||
await PageObjects.lens.configureDimension({
|
||||
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
|
||||
operation: 'cumulative_sum',
|
||||
});
|
||||
expect(await PageObjects.lens.getErrorCount()).to.eql(1);
|
||||
|
||||
await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel');
|
||||
expect(await PageObjects.lens.getErrorCount()).to.eql(2);
|
||||
|
||||
await PageObjects.lens.dragFieldToDimensionTrigger(
|
||||
'@timestamp',
|
||||
'lnsXY_xDimensionPanel > lns-empty-dimension'
|
||||
);
|
||||
expect(await PageObjects.lens.getErrorCount()).to.eql(1);
|
||||
|
||||
expect(await PageObjects.lens.hasChartSwitchWarning('lnsDatatable')).to.eql(false);
|
||||
await PageObjects.lens.switchToVisualization('lnsDatatable');
|
||||
|
||||
expect(await PageObjects.lens.getDimensionTriggerText('lnsDatatable_metrics')).to.eql(
|
||||
'Cumulative sum of (incomplete)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow to change index pattern', async () => {
|
||||
await PageObjects.lens.switchFirstLayerIndexPattern('log*');
|
||||
expect(await PageObjects.lens.getFirstLayerIndexPattern()).to.equal('log*');
|
||||
|
|
|
@ -122,6 +122,32 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Changes the specified dimension to the specified operation and (optinally) field.
|
||||
*
|
||||
* @param opts.dimension - the selector of the dimension being changed
|
||||
* @param opts.operation - the desired operation ID for the dimension
|
||||
* @param opts.field - the desired field for the dimension
|
||||
* @param layerIndex - the index of the layer
|
||||
*/
|
||||
async configureReference(opts: {
|
||||
operation?: string;
|
||||
field?: string;
|
||||
isPreviousIncompatible?: boolean;
|
||||
}) {
|
||||
if (opts.operation) {
|
||||
const target = await testSubjects.find('indexPattern-subFunction-selection-row');
|
||||
await comboBox.openOptionsList(target);
|
||||
await comboBox.setElement(target, opts.operation);
|
||||
}
|
||||
|
||||
if (opts.field) {
|
||||
const target = await testSubjects.find('indexPattern-reference-field-selection-row');
|
||||
await comboBox.openOptionsList(target);
|
||||
await comboBox.setElement(target, opts.field);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Drags field to workspace
|
||||
*
|
||||
|
@ -327,6 +353,19 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
|
|||
});
|
||||
},
|
||||
|
||||
/** Counts the visible warnings in the config panel */
|
||||
async getErrorCount() {
|
||||
const moreButton = await testSubjects.exists('configuration-failure-more-errors');
|
||||
if (moreButton) {
|
||||
await retry.try(async () => {
|
||||
await testSubjects.click('configuration-failure-more-errors');
|
||||
await testSubjects.missingOrFail('configuration-failure-more-errors');
|
||||
});
|
||||
}
|
||||
const errors = await testSubjects.findAll('configuration-failure-error');
|
||||
return errors?.length ?? 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks a specific subvisualization in the chart switcher for a "data loss" indicator
|
||||
*
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue