mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Lens] Provide single-value functions to show the "First" or "Last" value of some field (#83437) (#84815)
This commit is contained in:
parent
8096c618a1
commit
88f38a7e12
32 changed files with 1256 additions and 127 deletions
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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">
|
||||
|
|
|
@ -15,6 +15,10 @@
|
|||
column-gap: $euiSizeXL;
|
||||
}
|
||||
|
||||
.lnsIndexPatternDimensionEditor__operation .euiListGroupItem__label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lnsIndexPatternDimensionEditor__operation > button {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
|
|
@ -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 }));
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -17,8 +17,6 @@ import { OperationMetadata } from '../../types';
|
|||
import { IndexPatternColumn } from '../operations';
|
||||
import { getFieldByNameFactory } from '../pure_helpers';
|
||||
|
||||
jest.mock('../operations');
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: 'timestamp',
|
||||
|
|
|
@ -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".',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -24,6 +24,7 @@ export const {
|
|||
getOperationResultType,
|
||||
operationDefinitionMap,
|
||||
operationDefinitions,
|
||||
getInvalidFieldMessage,
|
||||
} = actualOperations;
|
||||
|
||||
export const {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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' &&
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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'>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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' &&
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue