[Lens] Handle missing fields gracefully (#78173)

This commit is contained in:
Joe Reuter 2020-10-01 13:39:10 +02:00 committed by GitHub
parent cbc83003d3
commit 8d7f2d0828
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 292 additions and 102 deletions

View file

@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiHorizontalRule, EuiRadio, EuiSelect, htmlIdGenerator } from '@elastic/eui';
import { IndexPatternLayer, IndexPatternField } from '../types';
import { hasField } from '../utils';
import { IndexPatternColumn } from '../operations';
const generator = htmlIdGenerator('lens-nesting');
@ -21,6 +22,10 @@ function nestColumn(columnOrder: string[], outer: string, inner: string) {
return result;
}
function getFieldName(fieldMap: Record<string, IndexPatternField>, column: IndexPatternColumn) {
return hasField(column) ? fieldMap[column.sourceField]?.displayName || column.sourceField : '';
}
export function BucketNestingEditor({
columnId,
layer,
@ -39,7 +44,7 @@ export function BucketNestingEditor({
.map(([value, c]) => ({
value,
text: c.label,
fieldName: hasField(c) ? fieldMap[c.sourceField].displayName : '',
fieldName: getFieldName(fieldMap, c),
operationType: c.operationType,
}));
@ -47,7 +52,7 @@ export function BucketNestingEditor({
return null;
}
const fieldName = hasField(column) ? fieldMap[column.sourceField].displayName : '';
const fieldName = getFieldName(fieldMap, column);
const prevColumn = layer.columnOrder[layer.columnOrder.indexOf(columnId) - 1];

View file

@ -26,7 +26,7 @@ import {
} from '../operations';
import { deleteColumn, changeColumn, updateColumnParam } from '../state_helpers';
import { FieldSelect } from './field_select';
import { hasField } from '../utils';
import { hasField, fieldIsInvalid } from '../utils';
import { BucketNestingEditor } from './bucket_nesting_editor';
import { IndexPattern, IndexPatternField } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
@ -132,6 +132,15 @@ export function DimensionEditor(props: DimensionEditorProps) {
};
});
const selectedColumnSourceField =
selectedColumn && 'sourceField' in selectedColumn ? selectedColumn.sourceField : undefined;
const currentFieldIsInvalid = useMemo(
() =>
fieldIsInvalid(selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern),
[selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern]
);
const sideNavItems: EuiListGroupItemProps[] = operationsWithCompatibility.map(
({ operationType, compatibleWithCurrentField }) => {
const isActive = Boolean(
@ -271,20 +280,16 @@ export function DimensionEditor(props: DimensionEditorProps) {
defaultMessage: 'Choose a field',
})}
fullWidth
isInvalid={Boolean(incompatibleSelectedOperationType)}
error={
selectedColumn && incompatibleSelectedOperationType
? selectedOperationDefinition?.input === 'field'
? i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
defaultMessage: 'To use this function, select a different field.',
})
: i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', {
defaultMessage: 'To use this function, select a field.',
})
: undefined
}
isInvalid={Boolean(incompatibleSelectedOperationType || currentFieldIsInvalid)}
error={getErrorMessage(
selectedColumn,
Boolean(incompatibleSelectedOperationType),
selectedOperationDefinition?.input,
currentFieldIsInvalid
)}
>
<FieldSelect
fieldIsInvalid={currentFieldIsInvalid}
currentIndexPattern={currentIndexPattern}
existingFields={state.existingFields}
fieldMap={fieldMap}
@ -355,90 +360,117 @@ export function DimensionEditor(props: DimensionEditorProps) {
</EuiFormRow>
) : null}
{!incompatibleSelectedOperationType && selectedColumn && ParamEditor && (
<>
<ParamEditor
state={state}
setState={setState}
columnId={columnId}
currentColumn={state.layers[layerId].columns[columnId]}
storage={props.storage}
uiSettings={props.uiSettings}
savedObjectsClient={props.savedObjectsClient}
layerId={layerId}
http={props.http}
dateRange={props.dateRange}
data={props.data}
/>
</>
)}
{!currentFieldIsInvalid &&
!incompatibleSelectedOperationType &&
selectedColumn &&
ParamEditor && (
<>
<ParamEditor
state={state}
setState={setState}
columnId={columnId}
currentColumn={state.layers[layerId].columns[columnId]}
storage={props.storage}
uiSettings={props.uiSettings}
savedObjectsClient={props.savedObjectsClient}
layerId={layerId}
http={props.http}
dateRange={props.dateRange}
data={props.data}
/>
</>
)}
</div>
<EuiSpacer size="s" />
<div className="lnsIndexPatternDimensionEditor__section">
{!incompatibleSelectedOperationType && selectedColumn && (
<LabelInput
value={selectedColumn.label}
onChange={(value) => {
setState({
...state,
layers: {
...state.layers,
[layerId]: {
...state.layers[layerId],
columns: {
...state.layers[layerId].columns,
[columnId]: {
...selectedColumn,
label: value,
customLabel: true,
{!currentFieldIsInvalid && (
<div className="lnsIndexPatternDimensionEditor__section">
{!incompatibleSelectedOperationType && selectedColumn && (
<LabelInput
value={selectedColumn.label}
onChange={(value) => {
setState({
...state,
layers: {
...state.layers,
[layerId]: {
...state.layers[layerId],
columns: {
...state.layers[layerId].columns,
[columnId]: {
...selectedColumn,
label: value,
customLabel: true,
},
},
},
},
},
});
}}
/>
)}
});
}}
/>
)}
{!hideGrouping && (
<BucketNestingEditor
fieldMap={fieldMap}
layer={state.layers[props.layerId]}
columnId={props.columnId}
setColumns={(columnOrder) => {
setState({
...state,
layers: {
...state.layers,
[props.layerId]: {
...state.layers[props.layerId],
columnOrder,
{!hideGrouping && (
<BucketNestingEditor
fieldMap={fieldMap}
layer={state.layers[props.layerId]}
columnId={props.columnId}
setColumns={(columnOrder) => {
setState({
...state,
layers: {
...state.layers,
[props.layerId]: {
...state.layers[props.layerId],
columnOrder,
},
},
},
});
}}
/>
)}
});
}}
/>
)}
{selectedColumn && selectedColumn.dataType === 'number' ? (
<FormatSelector
selectedColumn={selectedColumn}
onChange={(newFormat) => {
setState(
updateColumnParam({
state,
layerId,
currentColumn: selectedColumn,
paramName: 'format',
value: newFormat,
})
);
}}
/>
) : null}
</div>
{selectedColumn && selectedColumn.dataType === 'number' ? (
<FormatSelector
selectedColumn={selectedColumn}
onChange={(newFormat) => {
setState(
updateColumnParam({
state,
layerId,
currentColumn: selectedColumn,
paramName: 'format',
value: newFormat,
})
);
}}
/>
) : null}
</div>
)}
</div>
);
}
function getErrorMessage(
selectedColumn: IndexPatternColumn | undefined,
incompatibleSelectedOperationType: boolean,
input: 'none' | 'field' | undefined,
fieldInvalid: boolean
) {
if (selectedColumn && incompatibleSelectedOperationType) {
if (input === 'field') {
return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
defaultMessage: 'To use this function, select a different field.',
});
}
return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', {
defaultMessage: 'To use this function, select a field.',
});
}
if (fieldInvalid) {
return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', {
defaultMessage: 'Invalid field. Check your index pattern or pick another field.',
});
}
}

View file

@ -25,6 +25,7 @@ import { IndexPatternPrivateState } from '../types';
import { IndexPatternColumn } from '../operations';
import { documentField } from '../document_field';
import { OperationMetadata } from '../../types';
import { DateHistogramIndexPatternColumn } from '../operations/definitions/date_histogram';
jest.mock('../loader');
jest.mock('../state_helpers');
@ -801,6 +802,35 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
});
it('should render invalid field if field reference is broken', () => {
wrapper = mount(
<IndexPatternDimensionEditorComponent
{...defaultProps}
state={{
...defaultProps.state,
layers: {
first: {
...defaultProps.state.layers.first,
columns: {
col1: {
...defaultProps.state.layers.first.columns.col1,
sourceField: 'nonexistent',
} as DateHistogramIndexPatternColumn,
},
},
},
}}
/>
);
expect(wrapper.find(EuiComboBox).prop('selectedOptions')).toEqual([
{
label: 'nonexistent',
value: { type: 'field', field: 'nonexistent' },
},
]);
});
it('should support selecting the operation before the field', () => {
wrapper = mount(<IndexPatternDimensionEditorComponent {...defaultProps} columnId={'col2'} />);

View file

@ -5,9 +5,9 @@
*/
import _ from 'lodash';
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiLink } from '@elastic/eui';
import { EuiLink, EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import {
@ -22,7 +22,7 @@ import { IndexPatternColumn, OperationType } from '../indexpattern';
import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations';
import { DimensionEditor } from './dimension_editor';
import { changeColumn } from '../state_helpers';
import { isDraggedField, hasField } from '../utils';
import { isDraggedField, hasField, fieldIsInvalid } from '../utils';
import { IndexPatternPrivateState, IndexPatternField } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { DateRange } from '../../../common';
@ -233,14 +233,63 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
props: IndexPatternDimensionTriggerProps
) {
const layerId = props.layerId;
const layer = props.state.layers[layerId];
const selectedColumn: IndexPatternColumn | null = layer.columns[props.columnId] || null;
const currentIndexPattern = props.state.indexPatterns[layer.indexPatternId];
const selectedColumn: IndexPatternColumn | null =
props.state.layers[layerId].columns[props.columnId] || null;
const selectedColumnSourceField =
selectedColumn && 'sourceField' in selectedColumn ? selectedColumn.sourceField : undefined;
const currentFieldIsInvalid = useMemo(
() =>
fieldIsInvalid(selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern),
[selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern]
);
const { columnId, uniqueLabel } = props;
if (!selectedColumn) {
return null;
}
if (currentFieldIsInvalid) {
return (
<EuiToolTip
content={
<p>
{i18n.translate('xpack.lens.configure.invalidConfigTooltip', {
defaultMessage: 'Invalid configuration.',
})}
<br />
{i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', {
defaultMessage: 'Click for more details.',
})}
</p>
}
anchorClassName="lnsLayerPanel__anchor"
>
<EuiLink
color="danger"
id={columnId}
className="lnsLayerPanel__triggerLink"
onClick={props.onClick}
data-test-subj="lns-dimensionTrigger"
aria-label={i18n.translate('xpack.lens.configure.editConfig', {
defaultMessage: 'Edit configuration',
})}
title={i18n.translate('xpack.lens.configure.editConfig', {
defaultMessage: 'Edit configuration',
})}
>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon size="s" type="alert" />
</EuiFlexItem>
<EuiFlexItem grow={true}>{selectedColumn.label}</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
</EuiToolTip>
);
}
return (
<EuiLink
id={columnId}

View file

@ -41,6 +41,7 @@ export interface FieldSelectProps extends EuiComboBoxProps<{}> {
onChoose: (choice: FieldChoice) => void;
onDeleteColumn: () => void;
existingFields: IndexPatternPrivateState['existingFields'];
fieldIsInvalid: boolean;
}
export function FieldSelect({
@ -53,6 +54,7 @@ export function FieldSelect({
onChoose,
onDeleteColumn,
existingFields,
fieldIsInvalid,
...rest
}: FieldSelectProps) {
const { operationByField } = operationSupportMatrix;
@ -171,12 +173,14 @@ export function FieldSelect({
defaultMessage: 'Field',
})}
options={(memoizedFieldOptions as unknown) as EuiComboBoxOptionOption[]}
isInvalid={Boolean(incompatibleSelectedOperationType)}
isInvalid={Boolean(incompatibleSelectedOperationType || fieldIsInvalid)}
selectedOptions={
((selectedColumnOperationType && selectedColumnSourceField
? [
{
label: fieldMap[selectedColumnSourceField].displayName,
label: fieldIsInvalid
? selectedColumnSourceField
: fieldMap[selectedColumnSourceField]?.displayName,
value: { type: 'field', field: selectedColumnSourceField },
},
]

View file

@ -147,7 +147,7 @@ function testInitialState(): IndexPatternPrivateState {
// Private
operationType: 'terms',
sourceField: 'op',
sourceField: 'dest',
params: {
size: 5,
orderBy: { type: 'alphabetical' },
@ -1115,7 +1115,7 @@ describe('IndexPattern Data Source suggestions', () => {
// Private
operationType: 'terms',
sourceField: 'op',
sourceField: 'dest',
params: {
size: 5,
orderBy: { type: 'alphabetical' },
@ -1615,7 +1615,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'field2',
sourceField: 'timestamp',
params: {
interval: 'd',
},
@ -1626,7 +1626,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: true,
operationType: 'terms',
sourceField: 'field1',
sourceField: 'dest',
params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc' },
},
id3: {
@ -1635,7 +1635,7 @@ describe('IndexPattern Data Source suggestions', () => {
isBucketed: false,
operationType: 'avg',
sourceField: 'field1',
sourceField: 'bytes',
},
},
columnOrder: ['id1', 'id2', 'id3'],
@ -1652,6 +1652,38 @@ describe('IndexPattern Data Source suggestions', () => {
})
);
});
it('does not generate suggestions if invalid fields are referenced', () => {
const initialState = testInitialState();
const state: IndexPatternPrivateState = {
indexPatternRefs: [],
existingFields: {},
currentIndexPatternId: '1',
indexPatterns: expectedIndexPatterns,
isFirstExistenceFetch: false,
layers: {
first: {
...initialState.layers.first,
columns: {
...initialState.layers.first.columns,
col2: {
label: 'Top 5',
dataType: 'string',
isBucketed: true,
operationType: 'terms',
sourceField: 'nonExistingField',
params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc' },
},
},
columnOrder: ['col1', 'col2'],
},
},
};
const suggestions = getDatasourceSuggestionsFromCurrentState(state);
expect(suggestions).toEqual([]);
});
});
});

View file

@ -18,7 +18,7 @@ import {
} from './operations';
import { operationDefinitions } from './operations/definitions';
import { TermsIndexPatternColumn } from './operations/definitions/terms';
import { hasField } from './utils';
import { hasField, hasInvalidReference } from './utils';
import {
IndexPattern,
IndexPatternPrivateState,
@ -90,6 +90,7 @@ export function getDatasourceSuggestionsForField(
indexPatternId: string,
field: IndexPatternField
): IndexPatternSugestion[] {
if (hasInvalidReference(state)) return [];
const layers = Object.keys(state.layers);
const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
@ -380,6 +381,7 @@ function createNewLayerWithMetricAggregation(
export function getDatasourceSuggestionsFromCurrentState(
state: IndexPatternPrivateState
): Array<DatasourceSuggestion<IndexPatternPrivateState>> {
if (hasInvalidReference(state)) return [];
const layers = Object.entries(state.layers || {});
if (layers.length > 1) {
// Return suggestions that reduce the data to each layer individually

View file

@ -5,11 +5,13 @@
*/
import { DataType } from '../types';
import { IndexPatternPrivateState, IndexPattern } from './types';
import { DraggedField } from './indexpattern';
import {
BaseIndexPatternColumn,
FieldBasedIndexPatternColumn,
} from './operations/definitions/column_types';
import { operationDefinitionMap, OperationType } from './operations';
/**
* Normalizes the specified operation type. (e.g. document operations
@ -40,3 +42,37 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg
'indexPatternId' in fieldCandidate
);
}
export function hasInvalidReference(state: IndexPatternPrivateState) {
return Object.values(state.layers).some((layer) => {
return layer.columnOrder.some((columnId) => {
const column = layer.columns[columnId];
return (
hasField(column) &&
fieldIsInvalid(
column.sourceField,
column.operationType,
state.indexPatterns[layer.indexPatternId]
)
);
});
});
}
export function fieldIsInvalid(
sourceField: string | undefined,
operationType: OperationType | undefined,
indexPattern: IndexPattern
) {
const operationDefinition = operationType && operationDefinitionMap[operationType];
return Boolean(
sourceField &&
operationDefinition &&
!indexPattern.fields.some(
(field) =>
field.name === sourceField &&
operationDefinition.input === 'field' &&
operationDefinition.getPossibleOperationForField(field) !== undefined
)
);
}