[Lens] Provide single-value functions to show the "First" or "Last" value of some field (#83437) (#84815)

This commit is contained in:
Marta Bondyra 2020-12-03 08:28:47 +01:00 committed by GitHub
parent 8096c618a1
commit 88f38a7e12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1256 additions and 127 deletions

View file

@ -0,0 +1,17 @@
.lnsWorkspaceWarning__button {
color: $euiColorWarningText;
}
.lnsWorkspaceWarningList {
@include euiYScroll;
max-height: $euiSize * 20;
width: $euiSize * 16;
}
.lnsWorkspaceWarningList__item {
padding: $euiSize;
& + & {
border-top: $euiBorderThin;
}
}

View file

@ -0,0 +1,57 @@
/*
* 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 './workspace_panel_wrapper.scss';
import './warnings_popover.scss';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiText, EuiButtonEmpty } from '@elastic/eui';
export const WarningsPopover = ({
children,
}: {
children?: React.ReactNode | React.ReactNode[];
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
if (!children) {
return null;
}
const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen);
const closePopover = () => setIsPopoverOpen(false);
const warningsCount = React.Children.count(children);
return (
<EuiPopover
panelPaddingSize="none"
button={
<EuiButtonEmpty
onClick={onButtonClick}
iconType="alert"
className="lnsWorkspaceWarning__button"
>
{i18n.translate('xpack.lens.chartWarnings.number', {
defaultMessage: `{warningsCount} {warningsCount, plural, one {warning} other {warnings}}`,
values: {
warningsCount,
},
})}
</EuiButtonEmpty>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
>
<ul className="lnsWorkspaceWarningList">
{React.Children.map(children, (child, index) => (
<li key={index} className="lnsWorkspaceWarningList__item">
<EuiText size="s">{child}</EuiText>
</li>
))}
</ul>
</EuiPopover>
);
};

View file

@ -19,6 +19,7 @@ import { Datasource, FramePublicAPI, Visualization } from '../../../types';
import { NativeRenderer } from '../../../native_renderer';
import { Action } from '../state_management';
import { ChartSwitch } from './chart_switch';
import { WarningsPopover } from './warnings_popover';
export interface WorkspacePanelWrapperProps {
children: React.ReactNode | React.ReactNode[];
@ -64,40 +65,59 @@ export function WorkspacePanelWrapper({
},
[dispatch, activeVisualization]
);
const warningMessages =
activeVisualization?.getWarningMessages &&
activeVisualization.getWarningMessages(visualizationState, framePublicAPI);
return (
<>
<div>
<EuiFlexGroup
alignItems="center"
gutterSize="m"
direction="row"
responsive={false}
wrap={true}
className="lnsWorkspacePanelWrapper__toolbar"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={visualizationMap}
visualizationId={visualizationId}
visualizationState={visualizationState}
datasourceMap={datasourceMap}
datasourceStates={datasourceStates}
dispatch={dispatch}
framePublicAPI={framePublicAPI}
/>
<EuiFlexGroup
gutterSize="m"
direction="row"
responsive={false}
wrap={true}
className="lnsWorkspacePanelWrapper__toolbar"
>
<EuiFlexItem grow={false}>
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={visualizationMap}
visualizationId={visualizationId}
visualizationState={visualizationState}
datasourceMap={datasourceMap}
datasourceStates={datasourceStates}
dispatch={dispatch}
framePublicAPI={framePublicAPI}
/>
</EuiFlexItem>
{activeVisualization && activeVisualization.renderToolbar && (
<EuiFlexItem grow={false}>
<NativeRenderer
render={activeVisualization.renderToolbar}
nativeProps={{
frame: framePublicAPI,
state: visualizationState,
setState: setVisualizationState,
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{warningMessages && warningMessages.length ? (
<WarningsPopover>{warningMessages}</WarningsPopover>
) : null}
</EuiFlexItem>
{activeVisualization && activeVisualization.renderToolbar && (
<EuiFlexItem grow={false}>
<NativeRenderer
render={activeVisualization.renderToolbar}
nativeProps={{
frame: framePublicAPI,
state: visualizationState,
setState: setVisualizationState,
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</div>
<EuiPageContent className="lnsWorkspacePanelWrapper">

View file

@ -15,6 +15,10 @@
column-gap: $euiSizeXL;
}
.lnsIndexPatternDimensionEditor__operation .euiListGroupItem__label {
width: 100%;
}
.lnsIndexPatternDimensionEditor__operation > button {
padding-top: 0;
padding-bottom: 0;

View file

@ -15,6 +15,7 @@ import {
EuiSpacer,
EuiListGroupItemProps,
EuiFormLabel,
EuiToolTip,
} from '@elastic/eui';
import { IndexPatternDimensionEditorProps } from './dimension_panel';
import { OperationSupportMatrix } from './operation_support';
@ -141,20 +142,19 @@ export function DimensionEditor(props: DimensionEditorProps) {
definition.input === 'field' &&
fieldByOperation[operationType]?.has(selectedColumn.sourceField)) ||
(selectedColumn && !hasField(selectedColumn) && definition.input === 'none'),
disabledStatus:
definition.getDisabledStatus &&
definition.getDisabledStatus(state.indexPatterns[state.currentIndexPatternId]),
};
});
const selectedColumnSourceField =
selectedColumn && 'sourceField' in selectedColumn ? selectedColumn.sourceField : undefined;
const currentFieldIsInvalid = useMemo(
() =>
fieldIsInvalid(selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern),
[selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern]
);
const currentFieldIsInvalid = useMemo(() => fieldIsInvalid(selectedColumn, currentIndexPattern), [
selectedColumn,
currentIndexPattern,
]);
const sideNavItems: EuiListGroupItemProps[] = operationsWithCompatibility.map(
({ operationType, compatibleWithCurrentField }) => {
({ operationType, compatibleWithCurrentField, disabledStatus }) => {
const isActive = Boolean(
incompleteOperation === operationType ||
(!incompleteOperation && selectedColumn && selectedColumn.operationType === operationType)
@ -168,7 +168,13 @@ export function DimensionEditor(props: DimensionEditorProps) {
}
let label: EuiListGroupItemProps['label'] = operationPanels[operationType].displayName;
if (isActive) {
if (disabledStatus) {
label = (
<EuiToolTip content={disabledStatus} display="block" position="left">
<span>{operationPanels[operationType].displayName}</span>
</EuiToolTip>
);
} else if (isActive) {
label = <strong>{operationPanels[operationType].displayName}</strong>;
}
@ -178,6 +184,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
color,
isActive,
size: 's',
isDisabled: !!disabledStatus,
className: 'lnsIndexPatternDimensionEditor__operation',
'data-test-subj': `lns-indexPatternDimension-${operationType}${
compatibleWithCurrentField ? '' : ' incompatible'
@ -264,7 +271,6 @@ export function DimensionEditor(props: DimensionEditorProps) {
? currentIndexPattern.getFieldByName(selectedColumn.sourceField)
: undefined,
});
setState(mergeLayer({ state, layerId, newLayer }));
},
};

View file

@ -1242,12 +1242,12 @@ describe('IndexPatternDimensionEditorPanel', () => {
expect(items.map(({ label }: { label: React.ReactNode }) => label)).toEqual([
'Average',
'Count',
'Last value',
'Maximum',
'Median',
'Minimum',
'Sum',
'Unique count',
'\u00a0',
]);
});

View file

@ -12,7 +12,7 @@ import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { DatasourceDimensionTriggerProps, DatasourceDimensionEditorProps } from '../../types';
import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public';
import { IndexPatternColumn } from '../indexpattern';
import { fieldIsInvalid } from '../utils';
import { isColumnInvalid } from '../utils';
import { IndexPatternPrivateState } from '../types';
import { DimensionEditor } from './dimension_editor';
import { DateRange } from '../../../common';
@ -45,24 +45,22 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
) {
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 { columnId, uniqueLabel } = props;
const selectedColumnSourceField =
selectedColumn && 'sourceField' in selectedColumn ? selectedColumn.sourceField : undefined;
const currentFieldIsInvalid = useMemo(
() =>
fieldIsInvalid(selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern),
[selectedColumnSourceField, selectedColumn?.operationType, currentIndexPattern]
const currentColumnHasErrors = useMemo(
() => isColumnInvalid(layer, columnId, currentIndexPattern),
[layer, columnId, currentIndexPattern]
);
const { columnId, uniqueLabel } = props;
const selectedColumn: IndexPatternColumn | null = layer.columns[props.columnId] || null;
if (!selectedColumn) {
return null;
}
const formattedLabel = wrapOnDot(uniqueLabel);
if (currentFieldIsInvalid) {
if (currentColumnHasErrors) {
return (
<EuiToolTip
content={

View file

@ -17,8 +17,6 @@ import { OperationMetadata } from '../../types';
import { IndexPatternColumn } from '../operations';
import { getFieldByNameFactory } from '../pure_helpers';
jest.mock('../operations');
const fields = [
{
name: 'timestamp',

View file

@ -819,7 +819,7 @@ describe('IndexPattern Data Source', () => {
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual({
shortMessage: 'Invalid reference.',
longMessage: 'Field "bytes" has an invalid reference.',
longMessage: '"Foo" has an invalid reference.',
});
});
@ -844,7 +844,7 @@ describe('IndexPattern Data Source', () => {
col2: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
label: 'Foo2',
operationType: 'count', // <= invalid
sourceField: 'memory',
},
@ -857,7 +857,7 @@ describe('IndexPattern Data Source', () => {
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual({
shortMessage: 'Invalid references.',
longMessage: 'Fields "bytes", "memory" have invalid reference.',
longMessage: '"Foo", "Foo2" have invalid reference.',
});
});
@ -882,7 +882,7 @@ describe('IndexPattern Data Source', () => {
col2: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
label: 'Foo2',
operationType: 'count', // <= invalid
sourceField: 'memory',
},
@ -909,11 +909,11 @@ describe('IndexPattern Data Source', () => {
expect(messages).toEqual([
{
shortMessage: 'Invalid references on Layer 1.',
longMessage: 'Layer 1 has invalid references in fields "bytes", "memory".',
longMessage: 'Layer 1 has invalid references in "Foo", "Foo2".',
},
{
shortMessage: 'Invalid reference on Layer 2.',
longMessage: 'Layer 2 has an invalid reference in field "source".',
longMessage: 'Layer 2 has an invalid reference in "Foo".',
},
]);
});

View file

@ -40,7 +40,7 @@ import {
} from './indexpattern_suggestions';
import {
getInvalidFieldsForLayer,
getInvalidColumnsForLayer,
getInvalidLayers,
isDraggedField,
normalizeOperationDataType,
@ -387,7 +387,7 @@ export function getIndexPatternDatasource({
}
})
.filter(Boolean) as Array<[number, number]>;
const invalidFieldsPerLayer: string[][] = getInvalidFieldsForLayer(
const invalidColumnsForLayer: string[][] = getInvalidColumnsForLayer(
invalidLayers,
state.indexPatterns
);
@ -397,33 +397,34 @@ export function getIndexPatternDatasource({
return [
...layerErrors,
...realIndex.map(([filteredIndex, layerIndex]) => {
const fieldsWithBrokenReferences: string[] = invalidFieldsPerLayer[filteredIndex].map(
(columnId) => {
const column = invalidLayers[filteredIndex].columns[
columnId
] as FieldBasedIndexPatternColumn;
return column.sourceField;
}
);
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 {fields, plural, one {reference} other {references}}.',
defaultMessage:
'Invalid {columns, plural, one {reference} other {references}}.',
values: {
fields: fieldsWithBrokenReferences.length,
columns: columnLabelsWithBrokenReferences.length,
},
}
),
longMessage: i18n.translate(
'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer',
{
defaultMessage: `{fieldsLength, plural, one {Field} other {Fields}} "{fields}" {fieldsLength, plural, one {has an} other {have}} invalid reference.`,
defaultMessage: `"{columns}" {columnsLength, plural, one {has an} other {have}} invalid reference.`,
values: {
fields: fieldsWithBrokenReferences.join('", "'),
fieldsLength: fieldsWithBrokenReferences.length,
columns: columnLabelsWithBrokenReferences.join('", "'),
columnsLength: columnLabelsWithBrokenReferences.length,
},
}
),
@ -432,18 +433,18 @@ export function getIndexPatternDatasource({
return {
shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', {
defaultMessage:
'Invalid {fieldsLength, plural, one {reference} other {references}} on Layer {layer}.',
'Invalid {columnsLength, plural, one {reference} other {references}} on Layer {layer}.',
values: {
layer: layerIndex,
fieldsLength: fieldsWithBrokenReferences.length,
columnsLength: columnLabelsWithBrokenReferences.length,
},
}),
longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', {
defaultMessage: `Layer {layer} has {fieldsLength, plural, one {an invalid} other {invalid}} {fieldsLength, plural, one {reference} other {references}} in {fieldsLength, plural, one {field} other {fields}} "{fields}".`,
defaultMessage: `Layer {layer} has {columnsLength, plural, one {an invalid} other {invalid}} {columnsLength, plural, one {reference} other {references}} in "{columns}".`,
values: {
layer: layerIndex,
fields: fieldsWithBrokenReferences.join('", "'),
fieldsLength: fieldsWithBrokenReferences.length,
columns: columnLabelsWithBrokenReferences.join('", "'),
columnsLength: columnLabelsWithBrokenReferences.length,
},
}),
};

View file

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

View file

@ -25,8 +25,6 @@ import {
import { createMockedRestrictedIndexPattern, createMockedIndexPattern } from './mocks';
import { documentField } from './document_field';
jest.mock('./operations');
const createMockStorage = (lastData?: Record<string, string>) => {
return {
get: jest.fn().mockImplementation(() => lastData),

View file

@ -24,6 +24,7 @@ export const {
getOperationResultType,
operationDefinitionMap,
operationDefinitions,
getInvalidFieldMessage,
} = actualOperations;
export const {

View file

@ -8,6 +8,8 @@ import { i18n } from '@kbn/i18n';
import { OperationDefinition } from './index';
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
import { getInvalidFieldMessage } from './helpers';
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
const SCALE = 'ratio';
@ -42,6 +44,8 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo
return { dataType: 'number', isBucketed: IS_BUCKETED, scale: SCALE };
}
},
getErrorMessage: (layer, columnId, indexPattern) =>
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
isTransferable: (column, newIndexPattern) => {
const newField = newIndexPattern.getFieldByName(column.sourceField);

View file

@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n';
import { OperationDefinition } from './index';
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
import { IndexPatternField } from '../../types';
import { getInvalidFieldMessage } from './helpers';
import {
adjustTimeScaleLabelSuffix,
adjustTimeScaleOnOtherColumnChange,
@ -29,6 +30,8 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
defaultMessage: 'Count',
}),
input: 'field',
getErrorMessage: (layer, columnId, indexPattern) =>
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
onFieldChange: (oldColumn, field) => {
return {
...oldColumn,

View file

@ -23,6 +23,7 @@ import { updateColumnParam } from '../layer_helpers';
import { OperationDefinition } from './index';
import { FieldBasedIndexPatternColumn } from './column_types';
import { IndexPatternAggRestrictions, search } from '../../../../../../../src/plugins/data/public';
import { getInvalidFieldMessage } from './helpers';
const { isValidInterval } = search.aggs;
const autoInterval = 'auto';
@ -46,6 +47,8 @@ export const dateHistogramOperation: OperationDefinition<
}),
input: 'field',
priority: 5, // Highest priority level used
getErrorMessage: (layer, columnId, indexPattern) =>
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
type === 'date' &&

View file

@ -6,6 +6,10 @@
import { useRef } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { i18n } from '@kbn/i18n';
import { operationDefinitionMap } from '.';
import { FieldBasedIndexPatternColumn } from './column_types';
import { IndexPattern } from '../../types';
export const useDebounceWithOptions = (
fn: Function,
@ -28,3 +32,33 @@ export const useDebounceWithOptions = (
newDeps
);
};
export function getInvalidFieldMessage(
column: FieldBasedIndexPatternColumn,
indexPattern?: IndexPattern
) {
if (!indexPattern) {
return;
}
const { sourceField, operationType } = column;
const field = sourceField ? indexPattern.getFieldByName(sourceField) : undefined;
const operationDefinition = operationType && operationDefinitionMap[operationType];
const isInvalid = Boolean(
sourceField &&
operationDefinition &&
!(
field &&
operationDefinition?.input === 'field' &&
operationDefinition.getPossibleOperationForField(field) !== undefined
)
);
return isInvalid
? [
i18n.translate('xpack.lens.indexPattern.fieldNotFound', {
defaultMessage: 'Field {invalidField} was not found',
values: { invalidField: sourceField },
}),
]
: undefined;
}

View file

@ -34,6 +34,7 @@ import {
MovingAverageIndexPatternColumn,
} from './calculations';
import { countOperation, CountIndexPatternColumn } from './count';
import { lastValueOperation, LastValueIndexPatternColumn } from './last_value';
import { StateSetter, OperationMetadata } from '../../../types';
import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
import {
@ -63,6 +64,7 @@ export type IndexPatternColumn =
| SumIndexPatternColumn
| MedianIndexPatternColumn
| CountIndexPatternColumn
| LastValueIndexPatternColumn
| CumulativeSumIndexPatternColumn
| CounterRateIndexPatternColumn
| DerivativeIndexPatternColumn
@ -85,6 +87,7 @@ const internalOperationDefinitions = [
cardinalityOperation,
sumOperation,
medianOperation,
lastValueOperation,
countOperation,
rangeOperation,
cumulativeSumOperation,
@ -99,6 +102,7 @@ export { filtersOperation } from './filters';
export { dateHistogramOperation } from './date_histogram';
export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics';
export { countOperation } from './count';
export { lastValueOperation } from './last_value';
export {
cumulativeSumOperation,
counterRateOperation,
@ -173,6 +177,24 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
*/
transfer?: (column: C, newIndexPattern: IndexPattern) => C;
/**
* if there is some reason to display the operation in the operations list
* but disable it from usage, this function returns the string describing
* the status. Otherwise it returns undefined
*/
getDisabledStatus?: (indexPattern: IndexPattern) => string | undefined;
/**
* Validate that the operation has the right preconditions in the state. For example:
*
* - Requires a date histogram operation somewhere before it in order
* - Missing references
*/
getErrorMessage?: (
layer: IndexPatternLayer,
columnId: string,
indexPattern?: IndexPattern
) => string[] | undefined;
/*
* Flag whether this operation can be scaled by time unit if a date histogram is available.
* If set to mandatory or optional, a UI element is shown in the config flyout to configure the time unit
* to scale by. The chosen unit will be persisted as `timeScale` property of the column.
@ -245,6 +267,17 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> {
* together with the agg configs returned from other columns.
*/
toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown;
/**
* Validate that the operation has the right preconditions in the state. For example:
*
* - Requires a date histogram operation somewhere before it in order
* - Missing references
*/
getErrorMessage: (
layer: IndexPatternLayer,
columnId: string,
indexPattern?: IndexPattern
) => string[] | undefined;
}
export interface RequiredReference {
@ -297,13 +330,6 @@ interface FullReferenceOperationDefinition<C extends BaseIndexPatternColumn> {
columnId: string,
indexPattern: IndexPattern
) => ExpressionFunctionAST[];
/**
* Validate that the operation has the right preconditions in the state. For example:
*
* - Requires a date histogram operation somewhere before it in order
* - Missing references
*/
getErrorMessage?: (layer: IndexPatternLayer, columnId: string) => string[] | undefined;
}
interface OperationDefinitionMap<C extends BaseIndexPatternColumn> {

View file

@ -0,0 +1,477 @@
/*
* 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 { shallow } from 'enzyme';
import { EuiComboBox } from '@elastic/eui';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { createMockedIndexPattern } from '../../mocks';
import { LastValueIndexPatternColumn } from './last_value';
import { lastValueOperation } from './index';
import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from '../../types';
const defaultProps = {
storage: {} as IStorageWrapper,
uiSettings: {} as IUiSettingsClient,
savedObjectsClient: {} as SavedObjectsClientContract,
dateRange: { fromDate: 'now-1d', toDate: 'now' },
data: dataPluginMock.createStartContract(),
http: {} as HttpSetup,
};
describe('last_value', () => {
let state: IndexPatternPrivateState;
const InlineOptions = lastValueOperation.paramEditor!;
beforeEach(() => {
const indexPattern = createMockedIndexPattern();
state = {
indexPatternRefs: [],
indexPatterns: {
'1': {
...indexPattern,
hasRestrictions: false,
} as IndexPattern,
},
existingFields: {},
currentIndexPatternId: '1',
isFirstExistenceFetch: false,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
label: 'Top value of category',
dataType: 'string',
isBucketed: true,
operationType: 'terms',
params: {
orderBy: { type: 'alphabetical' },
size: 3,
orderDirection: 'asc',
},
sourceField: 'category',
},
col2: {
label: 'Last value of a',
dataType: 'number',
isBucketed: false,
sourceField: 'a',
operationType: 'last_value',
params: {
sortField: 'datefield',
},
},
},
},
},
};
});
describe('toEsAggsConfig', () => {
it('should reflect params correctly', () => {
const lastValueColumn = state.layers.first.columns.col2 as LastValueIndexPatternColumn;
const esAggsConfig = lastValueOperation.toEsAggsConfig(
{ ...lastValueColumn, params: { ...lastValueColumn.params } },
'col1',
{} as IndexPattern
);
expect(esAggsConfig).toEqual(
expect.objectContaining({
params: expect.objectContaining({
aggregate: 'concat',
field: 'a',
size: 1,
sortField: 'datefield',
sortOrder: 'desc',
}),
})
);
});
});
describe('onFieldChange', () => {
it('should change correctly to new field', () => {
const oldColumn: LastValueIndexPatternColumn = {
operationType: 'last_value',
sourceField: 'source',
label: 'Last value of source',
isBucketed: true,
dataType: 'string',
params: {
sortField: 'datefield',
},
};
const indexPattern = createMockedIndexPattern();
const newNumberField = indexPattern.getFieldByName('bytes')!;
const column = lastValueOperation.onFieldChange(oldColumn, newNumberField);
expect(column).toEqual(
expect.objectContaining({
dataType: 'number',
sourceField: 'bytes',
params: expect.objectContaining({
sortField: 'datefield',
}),
})
);
expect(column.label).toContain('bytes');
});
it('should remove numeric parameters when changing away from number', () => {
const oldColumn: LastValueIndexPatternColumn = {
operationType: 'last_value',
sourceField: 'bytes',
label: 'Last value of bytes',
isBucketed: false,
dataType: 'number',
params: {
sortField: 'datefield',
},
};
const indexPattern = createMockedIndexPattern();
const newStringField = indexPattern.fields.find((i) => i.name === 'source')!;
const column = lastValueOperation.onFieldChange(oldColumn, newStringField);
expect(column).toHaveProperty('dataType', 'string');
expect(column).toHaveProperty('sourceField', 'source');
expect(column.params.format).toBeUndefined();
});
});
describe('getPossibleOperationForField', () => {
it('should return operation with the right type', () => {
expect(
lastValueOperation.getPossibleOperationForField({
aggregatable: true,
searchable: true,
name: 'test',
displayName: 'test',
type: 'boolean',
})
).toEqual({
dataType: 'boolean',
isBucketed: false,
scale: 'ratio',
});
expect(
lastValueOperation.getPossibleOperationForField({
aggregatable: true,
searchable: true,
name: 'test',
displayName: 'test',
type: 'ip',
})
).toEqual({
dataType: 'ip',
isBucketed: false,
scale: 'ratio',
});
});
it('should not return an operation if restrictions prevent terms', () => {
expect(
lastValueOperation.getPossibleOperationForField({
aggregatable: true,
searchable: true,
name: 'test',
displayName: 'test',
type: 'string',
aggregationRestrictions: {
terms: {
agg: 'terms',
},
},
})
).toEqual(undefined);
expect(
lastValueOperation.getPossibleOperationForField({
aggregatable: true,
aggregationRestrictions: {},
searchable: true,
name: 'test',
displayName: 'test',
type: 'string',
})
).toEqual(undefined);
// does it have to be aggregatable?
expect(
lastValueOperation.getPossibleOperationForField({
aggregatable: false,
searchable: true,
name: 'test',
displayName: 'test',
type: 'string',
})
).toEqual({ dataType: 'string', isBucketed: false, scale: 'ordinal' });
});
});
describe('buildColumn', () => {
it('should use type from the passed field', () => {
const lastValueColumn = lastValueOperation.buildColumn({
indexPattern: createMockedIndexPattern(),
field: {
aggregatable: true,
searchable: true,
type: 'boolean',
name: 'test',
displayName: 'test',
},
layer: { columns: {}, columnOrder: [], indexPatternId: '' },
});
expect(lastValueColumn.dataType).toEqual('boolean');
});
it('should use indexPattern timeFieldName as a default sortField', () => {
const lastValueColumn = lastValueOperation.buildColumn({
indexPattern: createMockedIndexPattern(),
layer: {
columns: {
col1: {
label: 'Count',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
},
columnOrder: [],
indexPatternId: '',
},
field: {
aggregatable: true,
searchable: true,
type: 'boolean',
name: 'test',
displayName: 'test',
},
});
expect(lastValueColumn.params).toEqual(
expect.objectContaining({
sortField: 'timestamp',
})
);
});
it('should use first indexPattern date field if there is no default timefieldName', () => {
const indexPattern = createMockedIndexPattern();
const indexPatternNoTimeField = {
...indexPattern,
timeFieldName: undefined,
fields: [
{
aggregatable: true,
searchable: true,
type: 'date',
name: 'datefield',
displayName: 'datefield',
},
{
aggregatable: true,
searchable: true,
type: 'boolean',
name: 'test',
displayName: 'test',
},
],
};
const lastValueColumn = lastValueOperation.buildColumn({
indexPattern: indexPatternNoTimeField,
layer: {
columns: {
col1: {
label: 'Count',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
},
columnOrder: [],
indexPatternId: '',
},
field: {
aggregatable: true,
searchable: true,
type: 'boolean',
name: 'test',
displayName: 'test',
},
});
expect(lastValueColumn.params).toEqual(
expect.objectContaining({
sortField: 'datefield',
})
);
});
});
it('should return disabledStatus if indexPattern does contain date field', () => {
const indexPattern = createMockedIndexPattern();
expect(lastValueOperation.getDisabledStatus!(indexPattern)).toEqual(undefined);
const indexPatternWithoutTimeFieldName = {
...indexPattern,
timeFieldName: undefined,
};
expect(lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName)).toEqual(
undefined
);
const indexPatternWithoutTimefields = {
...indexPatternWithoutTimeFieldName,
fields: indexPattern.fields.filter((f) => f.type !== 'date'),
};
const disabledStatus = lastValueOperation.getDisabledStatus!(indexPatternWithoutTimefields);
expect(disabledStatus).toEqual(
'This function requires the presence of a date field in your index'
);
});
describe('param editor', () => {
it('should render current sortField', () => {
const setStateSpy = jest.fn();
const instance = shallow(
<InlineOptions
{...defaultProps}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col2 as LastValueIndexPatternColumn}
layerId="first"
/>
);
const select = instance.find('[data-test-subj="lns-indexPattern-lastValue-sortField"]');
expect(select.prop('selectedOptions')).toEqual([{ label: 'datefield', value: 'datefield' }]);
});
it('should update state when changing sortField', () => {
const setStateSpy = jest.fn();
const instance = shallow(
<InlineOptions
{...defaultProps}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col2 as LastValueIndexPatternColumn}
layerId="first"
/>
);
instance
.find('[data-test-subj="lns-indexPattern-lastValue-sortField"]')
.find(EuiComboBox)
.prop('onChange')!([{ label: 'datefield2', value: 'datefield2' }]);
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col2: {
...state.layers.first.columns.col2,
params: {
...(state.layers.first.columns.col2 as LastValueIndexPatternColumn).params,
sortField: 'datefield2',
},
},
},
},
},
});
});
});
describe('getErrorMessage', () => {
let indexPattern: IndexPattern;
let layer: IndexPatternLayer;
beforeEach(() => {
indexPattern = createMockedIndexPattern();
layer = {
columns: {
col1: {
dataType: 'boolean',
isBucketed: false,
label: 'Last value of test',
operationType: 'last_value',
params: { sortField: 'timestamp' },
scale: 'ratio',
sourceField: 'bytes',
},
},
columnOrder: [],
indexPatternId: '',
};
});
it('returns undefined if sourceField exists and sortField is of type date ', () => {
expect(lastValueOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual(undefined);
});
it('shows error message if the sourceField does not exist in index pattern', () => {
layer = {
...layer,
columns: {
col1: {
...layer.columns.col1,
sourceField: 'notExisting',
} as LastValueIndexPatternColumn,
},
};
expect(lastValueOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([
'Field notExisting was not found',
]);
});
it('shows error message if the sortField does not exist in index pattern', () => {
layer = {
...layer,
columns: {
col1: {
...layer.columns.col1,
params: {
...layer.columns.col1.params,
sortField: 'notExisting',
},
} as LastValueIndexPatternColumn,
},
};
expect(lastValueOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([
'Field notExisting was not found',
]);
});
it('shows error message if the sortField is not date', () => {
layer = {
...layer,
columns: {
col1: {
...layer.columns.col1,
params: {
...layer.columns.col1.params,
sortField: 'bytes',
},
} as LastValueIndexPatternColumn,
},
};
expect(lastValueOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([
'Field bytes is not a date field and cannot be used for sorting',
]);
});
});
});

View file

@ -0,0 +1,257 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { OperationDefinition } from './index';
import { FieldBasedIndexPatternColumn } from './column_types';
import { IndexPatternField, IndexPattern } from '../../types';
import { updateColumnParam } from '../layer_helpers';
import { DataType } from '../../../types';
import { getInvalidFieldMessage } from './helpers';
function ofName(name: string) {
return i18n.translate('xpack.lens.indexPattern.lastValueOf', {
defaultMessage: 'Last value of {name}',
values: { name },
});
}
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']);
export function getInvalidSortFieldMessage(sortField: string, indexPattern?: IndexPattern) {
if (!indexPattern) {
return;
}
const field = indexPattern.getFieldByName(sortField);
if (!field) {
return i18n.translate('xpack.lens.indexPattern.lastValue.sortFieldNotFound', {
defaultMessage: 'Field {invalidField} was not found',
values: { invalidField: sortField },
});
}
if (field.type !== 'date') {
return i18n.translate('xpack.lens.indexPattern.lastValue.invalidTypeSortField', {
defaultMessage: 'Field {invalidField} is not a date field and cannot be used for sorting',
values: { invalidField: sortField },
});
}
}
function isTimeFieldNameDateField(indexPattern: IndexPattern) {
return (
indexPattern.timeFieldName &&
indexPattern.fields.find(
(field) => field.name === indexPattern.timeFieldName && field.type === 'date'
)
);
}
function getDateFields(indexPattern: IndexPattern): IndexPatternField[] {
const dateFields = indexPattern.fields.filter((field) => field.type === 'date');
if (isTimeFieldNameDateField(indexPattern)) {
dateFields.sort(({ name: nameA }, { name: nameB }) => {
if (nameA === indexPattern.timeFieldName) {
return -1;
}
if (nameB === indexPattern.timeFieldName) {
return 1;
}
return 0;
});
}
return dateFields;
}
export interface LastValueIndexPatternColumn extends FieldBasedIndexPatternColumn {
operationType: 'last_value';
params: {
sortField: string;
// last value on numeric fields can be formatted
format?: {
id: string;
params?: {
decimals: number;
};
};
};
}
export const lastValueOperation: OperationDefinition<LastValueIndexPatternColumn, 'field'> = {
type: 'last_value',
displayName: i18n.translate('xpack.lens.indexPattern.lastValue', {
defaultMessage: 'Last value',
}),
getDefaultLabel: (column, indexPattern) =>
indexPattern.getFieldByName(column.sourceField)!.displayName,
input: 'field',
onFieldChange: (oldColumn, field) => {
const newParams = { ...oldColumn.params };
if ('format' in newParams && field.type !== 'number') {
delete newParams.format;
}
return {
...oldColumn,
dataType: field.type as DataType,
label: ofName(field.displayName),
sourceField: field.name,
params: newParams,
};
},
getPossibleOperationForField: ({ aggregationRestrictions, type }) => {
if (supportedTypes.has(type) && !aggregationRestrictions) {
return {
dataType: type as DataType,
isBucketed: false,
scale: type === 'string' ? 'ordinal' : 'ratio',
};
}
},
getDisabledStatus(indexPattern: IndexPattern) {
const hasDateFields = indexPattern && getDateFields(indexPattern).length;
if (!hasDateFields) {
return i18n.translate('xpack.lens.indexPattern.lastValue.disabled', {
defaultMessage: 'This function requires the presence of a date field in your index',
});
}
},
getErrorMessage(layer, columnId, indexPattern) {
const column = layer.columns[columnId] as LastValueIndexPatternColumn;
let errorMessages: string[] = [];
const invalidSourceFieldMessage = getInvalidFieldMessage(column, indexPattern);
const invalidSortFieldMessage = getInvalidSortFieldMessage(
column.params.sortField,
indexPattern
);
if (invalidSourceFieldMessage) {
errorMessages = [...invalidSourceFieldMessage];
}
if (invalidSortFieldMessage) {
errorMessages = [invalidSortFieldMessage];
}
return errorMessages.length ? errorMessages : undefined;
},
buildColumn({ field, previousColumn, indexPattern }) {
const sortField = isTimeFieldNameDateField(indexPattern)
? indexPattern.timeFieldName
: indexPattern.fields.find((f) => f.type === 'date')?.name;
if (!sortField) {
throw new Error(
i18n.translate('xpack.lens.functions.lastValue.missingSortField', {
defaultMessage: 'This index pattern does not contain any date fields',
})
);
}
return {
label: ofName(field.displayName),
dataType: field.type as DataType,
operationType: 'last_value',
isBucketed: false,
scale: field.type === 'string' ? 'ordinal' : 'ratio',
sourceField: field.name,
params: {
sortField,
},
};
},
toEsAggsConfig: (column, columnId) => ({
id: columnId,
enabled: true,
schema: 'metric',
type: 'top_hits',
params: {
field: column.sourceField,
aggregate: 'concat',
size: 1,
sortOrder: 'desc',
sortField: column.params.sortField,
},
}),
isTransferable: (column, newIndexPattern) => {
const newField = newIndexPattern.getFieldByName(column.sourceField);
const newTimeField = newIndexPattern.getFieldByName(column.params.sortField);
return Boolean(
newField &&
newField.type === column.dataType &&
!newField.aggregationRestrictions &&
newTimeField?.type === 'date'
);
},
paramEditor: ({ state, setState, currentColumn, layerId }) => {
const currentIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId];
const dateFields = getDateFields(currentIndexPattern);
const isSortFieldInvalid = !!getInvalidSortFieldMessage(
currentColumn.params.sortField,
currentIndexPattern
);
return (
<>
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.lastValue.sortField', {
defaultMessage: 'Sort by date field',
})}
display="columnCompressed"
fullWidth
error={i18n.translate('xpack.lens.indexPattern.sortField.invalid', {
defaultMessage: 'Invalid field. Check your index pattern or pick another field.',
})}
isInvalid={isSortFieldInvalid}
>
<EuiComboBox
placeholder={i18n.translate('xpack.lens.indexPattern.lastValue.sortFieldPlaceholder', {
defaultMessage: 'Sort field',
})}
compressed
isClearable={false}
data-test-subj="lns-indexPattern-lastValue-sortField"
isInvalid={isSortFieldInvalid}
singleSelection={{ asPlainText: true }}
aria-label={i18n.translate('xpack.lens.indexPattern.lastValue.sortField', {
defaultMessage: 'Sort by date field',
})}
options={dateFields?.map((field: IndexPatternField) => {
return {
value: field.name,
label: field.displayName,
};
})}
onChange={(choices) => {
if (choices.length === 0) {
return;
}
setState(
updateColumnParam({
state,
layerId,
currentColumn,
paramName: 'sortField',
value: choices[0].value,
})
);
}}
selectedOptions={
((currentColumn.params?.sortField
? [
{
label:
currentIndexPattern.getFieldByName(currentColumn.params.sortField)
?.displayName || currentColumn.params.sortField,
value: currentColumn.params.sortField,
},
]
: []) as unknown) as EuiComboBoxOptionOption[]
}
/>
</EuiFormRow>
</>
);
},
};

View file

@ -6,6 +6,7 @@
import { i18n } from '@kbn/i18n';
import { OperationDefinition } from './index';
import { getInvalidFieldMessage } from './helpers';
import {
FormattedIndexPatternColumn,
FieldBasedIndexPatternColumn,
@ -103,6 +104,8 @@ function buildMetricOperation<T extends MetricColumn<string>>({
missing: 0,
},
}),
getErrorMessage: (layer, columnId, indexPattern) =>
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
} as OperationDefinition<T, 'field'>;
}

View file

@ -17,6 +17,7 @@ import { mergeLayer } from '../../../state_helpers';
import { supportedFormats } from '../../../format_column';
import { MODES, AUTO_BARS, DEFAULT_INTERVAL, MIN_HISTOGRAM_BARS, SLICES } from './constants';
import { IndexPattern, IndexPatternField } from '../../../types';
import { getInvalidFieldMessage } from '../helpers';
type RangeType = Omit<Range, 'type'>;
// Try to cover all possible serialized states for ranges
@ -109,6 +110,8 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field
}),
priority: 4, // Higher than terms, so numbers get histogram
input: 'field',
getErrorMessage: (layer, columnId, indexPattern) =>
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
type === 'number' &&

View file

@ -22,6 +22,7 @@ import { DataType } from '../../../../types';
import { OperationDefinition } from '../index';
import { FieldBasedIndexPatternColumn } from '../column_types';
import { ValuesRangeInput } from './values_range_input';
import { getInvalidFieldMessage } from '../helpers';
function ofName(name: string) {
return i18n.translate('xpack.lens.indexPattern.termsOf', {
@ -31,7 +32,7 @@ function ofName(name: string) {
}
function isSortableByColumn(column: IndexPatternColumn) {
return !column.isBucketed;
return !column.isBucketed && column.operationType !== 'last_value';
}
const DEFAULT_SIZE = 3;
@ -71,6 +72,8 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field
return { dataType: type as DataType, isBucketed: true, scale: 'ordinal' };
}
},
getErrorMessage: (layer, columnId, indexPattern) =>
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
isTransferable: (column, newIndexPattern) => {
const newField = newIndexPattern.getFieldByName(column.sourceField);

View file

@ -15,7 +15,7 @@ import { createMockedIndexPattern } from '../../../mocks';
import { ValuesRangeInput } from './values_range_input';
import { TermsIndexPatternColumn } from '.';
import { termsOperation } from '../index';
import { IndexPatternPrivateState, IndexPattern } from '../../../types';
import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from '../../../types';
const defaultProps = {
storage: {} as IStorageWrapper,
@ -368,7 +368,7 @@ describe('terms', () => {
});
describe('onOtherColumnChanged', () => {
it('should keep the column if order by column still exists and is metric', () => {
it('should keep the column if order by column still exists and is isSortableByColumn metric', () => {
const initialColumn: TermsIndexPatternColumn = {
label: 'Top value of category',
dataType: 'string',
@ -395,6 +395,40 @@ describe('terms', () => {
expect(updatedColumn).toBe(initialColumn);
});
it('should switch to alphabetical ordering if metric is of type last_value', () => {
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!(initialColumn, {
col1: {
label: 'Last Value',
dataType: 'number',
isBucketed: false,
sourceField: 'bytes',
operationType: 'last_value',
params: {
sortField: 'time',
},
},
});
expect(updatedColumn.params).toEqual(
expect.objectContaining({
orderBy: { type: 'alphabetical' },
})
);
});
it('should switch to alphabetical ordering if there are no columns to order by', () => {
const termsColumn = termsOperation.onOtherColumnChanged!(
{
@ -770,4 +804,49 @@ describe('terms', () => {
});
});
});
describe('getErrorMessage', () => {
let indexPattern: IndexPattern;
let layer: IndexPatternLayer;
beforeEach(() => {
indexPattern = createMockedIndexPattern();
layer = {
columns: {
col1: {
dataType: 'boolean',
isBucketed: true,
label: 'Top values of bytes',
operationType: 'terms',
params: {
missingBucket: false,
orderBy: { type: 'alphabetical' },
orderDirection: 'asc',
otherBucket: true,
size: 5,
},
scale: 'ordinal',
sourceField: 'bytes',
},
},
columnOrder: [],
indexPatternId: '',
};
});
it('returns undefined if sourceField exists in index pattern', () => {
expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual(undefined);
});
it('returns error message if the sourceField does not exist in index pattern', () => {
layer = {
...layer,
columns: {
col1: {
...layer.columns.col1,
sourceField: 'notExisting',
} as TermsIndexPatternColumn,
},
};
expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([
'Field notExisting was not found',
]);
});
});
});

View file

@ -1706,7 +1706,6 @@ describe('state_helpers', () => {
describe('getErrorMessages', () => {
it('should collect errors from the operation definitions', () => {
const mock = jest.fn().mockReturnValue(['error 1']);
// @ts-expect-error not statically analyzed
operationDefinitionMap.testReference.getErrorMessage = mock;
const errors = getErrorMessages({
indexPatternId: '1',

View file

@ -35,5 +35,6 @@ export const createMockedReferenceOperation = () => {
toExpression: jest.fn().mockReturnValue([]),
getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }),
getDefaultLabel: jest.fn().mockReturnValue('Default label'),
getErrorMessage: jest.fn(),
};
};

View file

@ -293,6 +293,25 @@ describe('getOperationTypesForField', () => {
"operationType": "median",
"type": "field",
},
Object {
"field": "bytes",
"operationType": "last_value",
"type": "field",
},
],
},
Object {
"operationMetaData": Object {
"dataType": "string",
"isBucketed": false,
"scale": "ordinal",
},
"operations": Array [
Object {
"field": "source",
"operationType": "last_value",
"type": "field",
},
],
},
]

View file

@ -11,7 +11,9 @@ import {
BaseIndexPatternColumn,
FieldBasedIndexPatternColumn,
} from './operations/definitions/column_types';
import { operationDefinitionMap, OperationType } from './operations';
import { operationDefinitionMap, IndexPatternColumn } from './operations';
import { getInvalidFieldMessage } from './operations/definitions/helpers';
/**
* Normalizes the specified operation type. (e.g. document operations
@ -42,60 +44,46 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg
);
}
export function hasInvalidFields(state: IndexPatternPrivateState) {
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) => {
const column = layer.columns[columnId];
return (
hasField(column) &&
fieldIsInvalid(
column.sourceField,
column.operationType,
state.indexPatterns[layer.indexPatternId]
)
);
});
return layer.columnOrder.some((columnId) =>
isColumnInvalid(layer, columnId, state.indexPatterns[layer.indexPatternId])
);
});
}
export function getInvalidFieldsForLayer(
export function getInvalidColumnsForLayer(
layers: IndexPatternLayer[],
indexPatternMap: Record<string, IndexPattern>
) {
return layers.map((layer) => {
return layer.columnOrder.filter((columnId) => {
const column = layer.columns[columnId];
return (
hasField(column) &&
fieldIsInvalid(
column.sourceField,
column.operationType,
indexPatternMap[layer.indexPatternId]
)
);
});
return layer.columnOrder.filter((columnId) =>
isColumnInvalid(layer, columnId, indexPatternMap[layer.indexPatternId])
);
});
}
export function fieldIsInvalid(
sourceField: string | undefined,
operationType: OperationType | undefined,
export function isColumnInvalid(
layer: IndexPatternLayer,
columnId: string,
indexPattern: IndexPattern
) {
const operationDefinition = operationType && operationDefinitionMap[operationType];
const field = sourceField ? indexPattern.getFieldByName(sourceField) : undefined;
const column = layer.columns[columnId];
return Boolean(
sourceField &&
operationDefinition &&
!(
field &&
operationDefinition?.input === 'field' &&
operationDefinition.getPossibleOperationForField(field) !== undefined
)
const operationDefinition = column.operationType && operationDefinitionMap[column.operationType];
return !!(
operationDefinition.getErrorMessage &&
operationDefinition.getErrorMessage(layer, columnId, indexPattern)
);
}
export function fieldIsInvalid(column: IndexPatternColumn | undefined, indexPattern: IndexPattern) {
if (!column || !hasField(column)) {
return false;
}
return !!getInvalidFieldMessage(column, indexPattern)?.length;
}

View file

@ -245,6 +245,34 @@ export const getPieVisualization = ({
);
},
getWarningMessages(state, frame) {
if (state?.layers.length === 0 || !frame.activeData) {
return;
}
const metricColumnsWithArrayValues = [];
for (const layer of state.layers) {
const { layerId, metric } = layer;
const rows = frame.activeData[layerId] && frame.activeData[layerId].rows;
if (!rows || !metric) {
break;
}
const columnToLabel = frame.datasourceLayers[layerId].getOperationForColumnId(metric)?.label;
const hasArrayValues = rows.some((row) => Array.isArray(row[metric]));
if (hasArrayValues) {
metricColumnsWithArrayValues.push(columnToLabel || metric);
}
}
return metricColumnsWithArrayValues.map((label) => (
<>
<strong>{label}</strong> contains array values. Your visualization may not render as
expected.
</>
));
},
getErrorMessages(state, frame) {
// not possible to break it?
return undefined;

View file

@ -598,6 +598,11 @@ export interface Visualization<T = unknown> {
state: T,
frame: FramePublicAPI
) => Array<{ shortMessage: string; longMessage: string }> | undefined;
/**
* The frame calls this function to display warnings about visualization
*/
getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined;
}
export interface LensFilterEvent {

View file

@ -775,4 +775,69 @@ describe('xy_visualization', () => {
]);
});
});
describe('#getWarningMessages', () => {
let mockDatasource: ReturnType<typeof createMockDatasource>;
let frame: ReturnType<typeof createMockFramePublicAPI>;
beforeEach(() => {
frame = createMockFramePublicAPI();
mockDatasource = createMockDatasource('testDatasource');
mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([
{ columnId: 'd' },
{ columnId: 'a' },
{ columnId: 'b' },
{ columnId: 'c' },
]);
frame.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
frame.activeData = {
first: {
type: 'datatable',
columns: [
{ id: 'a', name: 'A', meta: { type: 'number' } },
{ id: 'b', name: 'B', meta: { type: 'number' } },
],
rows: [
{ a: 1, b: [2, 0] },
{ a: 3, b: 4 },
{ a: 5, b: 6 },
{ a: 7, b: 8 },
],
},
};
});
it('should return a warning when numeric accessors contain array', () => {
(frame.datasourceLayers.first.getOperationForColumnId as jest.Mock).mockReturnValue({
label: 'Label B',
});
const warningMessages = xyVisualization.getWarningMessages!(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: 'a',
accessors: ['b'],
},
],
},
frame
);
expect(warningMessages).toHaveLength(1);
expect(warningMessages && warningMessages[0]).toMatchInlineSnapshot(`
<React.Fragment>
<strong>
Label B
</strong>
contains array values. Your visualization may not render as expected.
</React.Fragment>
`);
});
});
});

View file

@ -22,6 +22,7 @@ import { LensIconChartBarStacked } from '../assets/chart_bar_stacked';
import { LensIconChartMixedXy } from '../assets/chart_mixed_xy';
import { LensIconChartBarHorizontal } from '../assets/chart_bar_horizontal';
import { getAccessorColorConfig, getColorAssignments } from './color_assignment';
import { getColumnToLabelMap } from './state_helpers';
const defaultIcon = LensIconChartBarStacked;
const defaultSeriesType = 'bar_stacked';
@ -371,6 +372,37 @@ export const getXyVisualization = ({
return errors.length ? errors : undefined;
},
getWarningMessages(state, frame) {
if (state?.layers.length === 0 || !frame.activeData) {
return;
}
const layers = state.layers;
const filteredLayers = layers.filter(({ accessors }: LayerConfig) => accessors.length > 0);
const accessorsWithArrayValues = [];
for (const layer of filteredLayers) {
const { layerId, accessors } = layer;
const rows = frame.activeData[layerId] && frame.activeData[layerId].rows;
if (!rows) {
break;
}
const columnToLabel = getColumnToLabelMap(layer, frame.datasourceLayers[layerId]);
for (const accessor of accessors) {
const hasArrayValues = rows.some((row) => Array.isArray(row[accessor]));
if (hasArrayValues) {
accessorsWithArrayValues.push(columnToLabel[accessor]);
}
}
}
return accessorsWithArrayValues.map((label) => (
<>
<strong>{label}</strong> contains array values. Your visualization may not render as
expected.
</>
));
},
});
function validateLayersForDimension(