[Lens] Visualization validation and better error messages (#81439)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Liberati 2020-11-04 18:28:00 +01:00 committed by GitHub
parent 89887984d8
commit 53ea09078f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1220 additions and 119 deletions

View file

@ -357,7 +357,7 @@ describe('Datatable Visualization', () => {
datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]);
datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType: 'string',
isBucketed: true,
isBucketed: false, // <= make them metrics
label: 'label',
});
@ -365,6 +365,7 @@ describe('Datatable Visualization', () => {
{ layers: [layer] },
frame.datasourceLayers
) as Ast;
const tableArgs = buildExpression(expression).findFunction('lens_datatable_columns');
expect(tableArgs).toHaveLength(1);
@ -372,5 +373,61 @@ describe('Datatable Visualization', () => {
columnIds: ['c', 'b'],
});
});
it('returns no expression if the metric dimension is not defined', () => {
const datasource = createMockDatasource('test');
const layer = { layerId: 'a', columns: ['b', 'c'] };
const frame = mockFrame();
frame.datasourceLayers = { a: datasource.publicAPIMock };
datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]);
datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType: 'string',
isBucketed: true, // move it from the metric to the break down by side
label: 'label',
});
const expression = datatableVisualization.toExpression(
{ layers: [layer] },
frame.datasourceLayers
);
expect(expression).toEqual(null);
});
});
describe('#getErrorMessages', () => {
it('returns undefined if the datasource is missing a metric dimension', () => {
const datasource = createMockDatasource('test');
const layer = { layerId: 'a', columns: ['b', 'c'] };
const frame = mockFrame();
frame.datasourceLayers = { a: datasource.publicAPIMock };
datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]);
datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType: 'string',
isBucketed: true, // move it from the metric to the break down by side
label: 'label',
});
const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame);
expect(error).not.toBeDefined();
});
it('returns undefined if the metric dimension is defined', () => {
const datasource = createMockDatasource('test');
const layer = { layerId: 'a', columns: ['b', 'c'] };
const frame = mockFrame();
frame.datasourceLayers = { a: datasource.publicAPIMock };
datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]);
datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType: 'string',
isBucketed: false, // keep it a metric
label: 'label',
});
const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame);
expect(error).not.toBeDefined();
});
});
});

View file

@ -6,7 +6,13 @@
import { Ast } from '@kbn/interpreter/common';
import { i18n } from '@kbn/i18n';
import { SuggestionRequest, Visualization, VisualizationSuggestion, Operation } from '../types';
import {
SuggestionRequest,
Visualization,
VisualizationSuggestion,
Operation,
DatasourcePublicAPI,
} from '../types';
import { LensIconChartDatatable } from '../assets/chart_datatable';
export interface LayerState {
@ -128,16 +134,13 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
},
getConfiguration({ state, frame, layerId }) {
const layer = state.layers.find((l) => l.layerId === layerId);
if (!layer) {
const { sortedColumns, datasource } =
getDataSourceAndSortedColumns(state, frame.datasourceLayers, layerId) || {};
if (!sortedColumns) {
return { groups: [] };
}
const datasource = frame.datasourceLayers[layer.layerId];
const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId);
// When we add a column it could be empty, and therefore have no order
const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns)));
return {
groups: [
{
@ -146,7 +149,9 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
defaultMessage: 'Break down by',
}),
layerId: state.layers[0].layerId,
accessors: sortedColumns.filter((c) => datasource.getOperationForColumnId(c)?.isBucketed),
accessors: sortedColumns.filter(
(c) => datasource!.getOperationForColumnId(c)?.isBucketed
),
supportsMoreColumns: true,
filterOperations: (op) => op.isBucketed,
dataTestSubj: 'lnsDatatable_column',
@ -158,7 +163,7 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
}),
layerId: state.layers[0].layerId,
accessors: sortedColumns.filter(
(c) => !datasource.getOperationForColumnId(c)?.isBucketed
(c) => !datasource!.getOperationForColumnId(c)?.isBucketed
),
supportsMoreColumns: true,
filterOperations: (op) => !op.isBucketed,
@ -194,14 +199,19 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
};
},
toExpression(state, datasourceLayers, { title, description } = {}): Ast {
const layer = state.layers[0];
const datasource = datasourceLayers[layer.layerId];
const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId);
// When we add a column it could be empty, and therefore have no order
const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns)));
const operations = sortedColumns
.map((columnId) => ({ columnId, operation: datasource.getOperationForColumnId(columnId) }))
toExpression(state, datasourceLayers, { title, description } = {}): Ast | null {
const { sortedColumns, datasource } =
getDataSourceAndSortedColumns(state, datasourceLayers, state.layers[0].layerId) || {};
if (
sortedColumns?.length &&
sortedColumns.filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed).length === 0
) {
return null;
}
const operations = sortedColumns!
.map((columnId) => ({ columnId, operation: datasource!.getOperationForColumnId(columnId) }))
.filter((o): o is { columnId: string; operation: Operation } => !!o.operation);
return {
@ -232,4 +242,24 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
],
};
},
getErrorMessages(state, frame) {
return undefined;
},
};
function getDataSourceAndSortedColumns(
state: DatatableVisualizationState,
datasourceLayers: Record<string, DatasourcePublicAPI>,
layerId: string
) {
const layer = state.layers.find((l: LayerState) => l.layerId === layerId);
if (!layer) {
return undefined;
}
const datasource = datasourceLayers[layer.layerId];
const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId);
// When we add a column it could be empty, and therefore have no order
const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns)));
return { datasource, sortedColumns };
}

View file

@ -6,7 +6,7 @@
import { SavedObjectReference } from 'kibana/public';
import { Ast } from '@kbn/interpreter/common';
import { Datasource, DatasourcePublicAPI, Visualization } from '../../types';
import { Datasource, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../../types';
import { buildExpression } from './expression_helpers';
import { Document } from '../../persistence/saved_object_store';
import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public';
@ -91,3 +91,29 @@ export async function persistedStateToExpression(
datasourceLayers,
});
}
export const validateDatasourceAndVisualization = (
currentDataSource: Datasource | null,
currentDatasourceState: unknown | null,
currentVisualization: Visualization | null,
currentVisualizationState: unknown | undefined,
frameAPI: FramePublicAPI
):
| Array<{
shortMessage: string;
longMessage: string;
}>
| undefined => {
const datasourceValidationErrors = currentDatasourceState
? currentDataSource?.getErrorMessages(currentDatasourceState)
: undefined;
const visualizationValidationErrors = currentVisualizationState
? currentVisualization?.getErrorMessages(currentVisualizationState, frameAPI)
: undefined;
if (datasourceValidationErrors || visualizationValidationErrors) {
return [...(datasourceValidationErrors || []), ...(visualizationValidationErrors || [])];
}
return undefined;
};

View file

@ -34,6 +34,7 @@ import {
import { prependDatasourceExpression } from './expression_helpers';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public';
import { validateDatasourceAndVisualization } from './state_helpers';
const MAX_SUGGESTIONS_DISPLAYED = 5;
@ -61,11 +62,28 @@ const PreviewRenderer = ({
withLabel,
ExpressionRendererComponent,
expression,
hasError,
}: {
withLabel: boolean;
expression: string;
expression: string | null | undefined;
ExpressionRendererComponent: ReactExpressionRendererType;
hasError: boolean;
}) => {
const onErrorMessage = (
<div className="lnsSuggestionPanel__suggestionIcon">
<EuiIconTip
size="xl"
color="danger"
type="alert"
aria-label={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
defaultMessage: 'Preview rendering failed',
})}
content={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
defaultMessage: 'Preview rendering failed',
})}
/>
</div>
);
return (
<div
className={classNames('lnsSuggestionPanel__chartWrapper', {
@ -73,29 +91,19 @@ const PreviewRenderer = ({
'lnsSuggestionPanel__chartWrapper--withLabel': withLabel,
})}
>
<ExpressionRendererComponent
className="lnsSuggestionPanel__expressionRenderer"
padding="s"
expression={expression}
debounce={2000}
renderError={() => {
return (
<div className="lnsSuggestionPanel__suggestionIcon">
<EuiIconTip
size="xl"
color="danger"
type="alert"
aria-label={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
defaultMessage: 'Preview rendering failed',
})}
content={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
defaultMessage: 'Preview rendering failed',
})}
/>
</div>
);
}}
/>
{!expression || hasError ? (
onErrorMessage
) : (
<ExpressionRendererComponent
className="lnsSuggestionPanel__expressionRenderer"
padding="s"
expression={expression}
debounce={2000}
renderError={() => {
return onErrorMessage;
}}
/>
)}
</div>
);
};
@ -112,6 +120,7 @@ const SuggestionPreview = ({
expression?: Ast | null;
icon: IconType;
title: string;
error?: boolean;
};
ExpressionRenderer: ReactExpressionRendererType;
selected: boolean;
@ -129,11 +138,12 @@ const SuggestionPreview = ({
data-test-subj="lnsSuggestion"
onClick={onSelect}
>
{preview.expression ? (
{preview.expression || preview.error ? (
<PreviewRenderer
ExpressionRendererComponent={ExpressionRendererComponent}
expression={toExpression(preview.expression)}
expression={preview.expression && toExpression(preview.expression)}
withLabel={Boolean(showTitleAsLabel)}
hasError={Boolean(preview.error)}
/>
) : (
<span className="lnsSuggestionPanel__suggestionIcon">
@ -170,47 +180,81 @@ export function SuggestionPanel({
? stagedPreview.visualization.activeId
: activeVisualizationId;
const { suggestions, currentStateExpression } = useMemo(() => {
const newSuggestions = getSuggestions({
datasourceMap,
datasourceStates: currentDatasourceStates,
visualizationMap,
activeVisualizationId: currentVisualizationId,
visualizationState: currentVisualizationState,
})
.map((suggestion) => ({
...suggestion,
previewExpression: preparePreviewExpression(
suggestion,
visualizationMap[suggestion.visualizationId],
datasourceMap,
currentDatasourceStates,
frame
),
}))
.filter((suggestion) => !suggestion.hide)
.slice(0, MAX_SUGGESTIONS_DISPLAYED);
const newStateExpression =
currentVisualizationState && currentVisualizationId
? preparePreviewExpression(
{ visualizationState: currentVisualizationState },
visualizationMap[currentVisualizationId],
const { suggestions, currentStateExpression, currentStateError } = useMemo(
() => {
const newSuggestions = getSuggestions({
datasourceMap,
datasourceStates: currentDatasourceStates,
visualizationMap,
activeVisualizationId: currentVisualizationId,
visualizationState: currentVisualizationState,
})
.filter((suggestion) => !suggestion.hide)
.filter(
({
visualizationId,
visualizationState: suggestionVisualizationState,
datasourceState: suggestionDatasourceState,
datasourceId: suggetionDatasourceId,
}) => {
return (
validateDatasourceAndVisualization(
suggetionDatasourceId ? datasourceMap[suggetionDatasourceId] : null,
suggestionDatasourceState,
visualizationMap[visualizationId],
suggestionVisualizationState,
frame
) == null
);
}
)
.slice(0, MAX_SUGGESTIONS_DISPLAYED)
.map((suggestion) => ({
...suggestion,
previewExpression: preparePreviewExpression(
suggestion,
visualizationMap[suggestion.visualizationId],
datasourceMap,
currentDatasourceStates,
frame
)
: undefined;
),
}));
return { suggestions: newSuggestions, currentStateExpression: newStateExpression };
const validationErrors = validateDatasourceAndVisualization(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId && currentDatasourceStates[activeDatasourceId]?.state,
currentVisualizationId ? visualizationMap[currentVisualizationId] : null,
currentVisualizationState,
frame
);
const newStateExpression =
currentVisualizationState && currentVisualizationId && !validationErrors
? preparePreviewExpression(
{ visualizationState: currentVisualizationState },
visualizationMap[currentVisualizationId],
datasourceMap,
currentDatasourceStates,
frame
)
: undefined;
return {
suggestions: newSuggestions,
currentStateExpression: newStateExpression,
currentStateError: validationErrors,
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
currentDatasourceStates,
currentVisualizationState,
currentVisualizationId,
datasourceMap,
visualizationMap,
]);
[
currentDatasourceStates,
currentVisualizationState,
currentVisualizationId,
activeDatasourceId,
datasourceMap,
visualizationMap,
]
);
const context: ExecutionContextSearch = useMemo(
() => ({
@ -305,6 +349,7 @@ export function SuggestionPanel({
{currentVisualizationId && (
<SuggestionPreview
preview={{
error: currentStateError != null,
expression: currentStateExpression,
icon:
visualizationMap[currentVisualizationId].getDescription(currentVisualizationState)

View file

@ -454,6 +454,132 @@ describe('workspace_panel', () => {
expect(expressionRendererMock).toHaveBeenCalledTimes(2);
});
it('should show an error message if validation on datasource does not pass', () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
]);
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
<WorkspacePanel
activeDatasourceId={'mock'}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
}}
framePublicAPI={framePublicAPI}
activeVisualizationId="vis"
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
/>
);
expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should show an error message if validation on visualization does not pass', () => {
mockDatasource.getErrorMessages.mockReturnValue(undefined);
mockDatasource.getLayers.mockReturnValue(['first']);
mockVisualization.getErrorMessages.mockReturnValue([
{ shortMessage: 'Some error happened', longMessage: 'Some long description happened' },
]);
mockVisualization.toExpression.mockReturnValue('vis');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
<WorkspacePanel
activeDatasourceId={'mock'}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
}}
framePublicAPI={framePublicAPI}
activeVisualizationId="vis"
visualizationMap={{
vis: mockVisualization,
}}
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
/>
);
expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should show an error message if validation on both datasource and visualization do not pass', () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
]);
mockDatasource.getLayers.mockReturnValue(['first']);
mockVisualization.getErrorMessages.mockReturnValue([
{ shortMessage: 'Some error happened', longMessage: 'Some long description happened' },
]);
mockVisualization.toExpression.mockReturnValue('vis');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
<WorkspacePanel
activeDatasourceId={'mock'}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
}}
framePublicAPI={framePublicAPI}
activeVisualizationId="vis"
visualizationMap={{
vis: mockVisualization,
}}
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
/>
);
// EuiFlexItem duplicates internally the attribute, so we need to filter only the most inner one here
expect(
instance.find('[data-test-subj="configuration-failure-more-errors"]').last().text()
).toEqual(' +1 error');
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should show an error message if the expression fails to parse', () => {
mockDatasource.toExpression.mockReturnValue('|||');
mockDatasource.getLayers.mockReturnValue(['first']);
@ -487,7 +613,7 @@ describe('workspace_panel', () => {
/>
);
expect(instance.find('[data-test-subj="expression-failure"]').first()).toBeTruthy();
expect(instance.find('[data-test-subj="expression-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});

View file

@ -9,7 +9,16 @@ import classNames from 'classnames';
import { FormattedMessage } from '@kbn/i18n/react';
import { Ast } from '@kbn/interpreter/common';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiButtonEmpty, EuiLink } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiText,
EuiTextColor,
EuiButtonEmpty,
EuiLink,
EuiTitle,
} from '@elastic/eui';
import { CoreStart, CoreSetup } from 'kibana/public';
import { ExecutionContextSearch } from 'src/plugins/expressions';
import {
@ -42,6 +51,7 @@ import {
import { WorkspacePanelWrapper } from './workspace_panel_wrapper';
import { DropIllustration } from '../../../assets/drop_illustration';
import { getOriginalRequestErrorMessage } from '../../error_helper';
import { validateDatasourceAndVisualization } from '../state_helpers';
export interface WorkspacePanelProps {
activeVisualizationId: string | null;
@ -66,7 +76,7 @@ export interface WorkspacePanelProps {
}
interface WorkspaceState {
expressionBuildError: string | undefined;
expressionBuildError?: Array<{ shortMessage: string; longMessage: string }>;
expandError: boolean;
}
@ -124,26 +134,58 @@ export function WorkspacePanel({
);
const [localState, setLocalState] = useState<WorkspaceState>({
expressionBuildError: undefined as string | undefined,
expressionBuildError: undefined,
expandError: false,
});
const activeVisualization = activeVisualizationId
? visualizationMap[activeVisualizationId]
: null;
// Note: mind to all these eslint disable lines: the frameAPI will change too frequently
// and to prevent race conditions it is ok to leave them there.
const configurationValidationError = useMemo(
() =>
validateDatasourceAndVisualization(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId && datasourceStates[activeDatasourceId]?.state,
activeVisualization,
visualizationState,
framePublicAPI
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeVisualization, visualizationState, activeDatasourceId, datasourceMap, datasourceStates]
);
const expression = useMemo(
() => {
try {
return buildExpression({
visualization: activeVisualization,
visualizationState,
datasourceMap,
datasourceStates,
datasourceLayers: framePublicAPI.datasourceLayers,
});
} catch (e) {
// Most likely an error in the expression provided by a datasource or visualization
setLocalState((s) => ({ ...s, expressionBuildError: e.toString() }));
if (!configurationValidationError) {
try {
return buildExpression({
visualization: activeVisualization,
visualizationState,
datasourceMap,
datasourceStates,
datasourceLayers: framePublicAPI.datasourceLayers,
});
} catch (e) {
const buildMessages = activeVisualization?.getErrorMessages(
visualizationState,
framePublicAPI
);
const defaultMessage = {
shortMessage: i18n.translate('xpack.lens.editorFrame.buildExpressionError', {
defaultMessage: 'An unexpected error occurred while preparing the chart',
}),
longMessage: e.toString(),
};
// Most likely an error in the expression provided by a datasource or visualization
setLocalState((s) => ({
...s,
expressionBuildError: buildMessages ?? [defaultMessage],
}));
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -256,7 +298,7 @@ export function WorkspacePanel({
timefilter={plugins.data.query.timefilter.timefilter}
onEvent={onEvent}
setLocalState={setLocalState}
localState={localState}
localState={{ ...localState, configurationValidationError }}
ExpressionRendererComponent={ExpressionRendererComponent}
/>
);
@ -304,7 +346,9 @@ export const InnerVisualizationWrapper = ({
timefilter: TimefilterContract;
onEvent: (event: ExpressionRendererEvent) => void;
setLocalState: (dispatch: (prevState: WorkspaceState) => WorkspaceState) => void;
localState: WorkspaceState;
localState: WorkspaceState & {
configurationValidationError?: Array<{ shortMessage: string; longMessage: string }>;
};
ExpressionRendererComponent: ReactExpressionRendererType;
}) => {
const autoRefreshFetch$ = useMemo(() => timefilter.getAutoRefreshFetch$(), [timefilter]);
@ -326,6 +370,66 @@ export const InnerVisualizationWrapper = ({
]
);
if (localState.configurationValidationError) {
let showExtraErrors = null;
if (localState.configurationValidationError.length > 1) {
if (localState.expandError) {
showExtraErrors = localState.configurationValidationError
.slice(1)
.map(({ longMessage }) => (
<EuiFlexItem key={longMessage} className="eui-textBreakAll">
{longMessage}
</EuiFlexItem>
));
} else {
showExtraErrors = (
<EuiFlexItem data-test-subj="configuration-failure-more-errors">
<EuiButtonEmpty
onClick={() => {
setLocalState((prevState: WorkspaceState) => ({
...prevState,
expandError: !prevState.expandError,
}));
}}
>
{i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', {
defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`,
values: { errors: localState.configurationValidationError.length - 1 },
})}
</EuiButtonEmpty>
</EuiFlexItem>
);
}
}
return (
<EuiFlexGroup
style={{ maxWidth: '100%' }}
direction="column"
alignItems="center"
data-test-subj="configuration-failure"
>
<EuiFlexItem>
<EuiIcon type="alert" size="xl" color="danger" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="s">
<EuiTextColor color="danger">
<FormattedMessage
id="xpack.lens.editorFrame.configurationFailure"
defaultMessage="Invalid configuration"
/>
</EuiTextColor>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem className="eui-textBreakAll">
{localState.configurationValidationError[0].longMessage}
</EuiFlexItem>
{showExtraErrors}
</EuiFlexGroup>
);
}
if (localState.expressionBuildError) {
return (
<EuiFlexGroup style={{ maxWidth: '100%' }} direction="column" alignItems="center">
@ -338,10 +442,11 @@ export const InnerVisualizationWrapper = ({
defaultMessage="An error occurred in the expression"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{localState.expressionBuildError}</EuiFlexItem>
<EuiFlexItem grow={false}>{localState.expressionBuildError[0].longMessage}</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<div className="lnsExpressionRenderer">
<ExpressionRendererComponent
@ -353,6 +458,7 @@ export const InnerVisualizationWrapper = ({
onEvent={onEvent}
renderError={(errorMessage?: string | null, error?: ExpressionRenderError | null) => {
const visibleErrorMessage = getOriginalRequestErrorMessage(error) || errorMessage;
return (
<EuiFlexGroup style={{ maxWidth: '100%' }} direction="column" alignItems="center">
<EuiFlexItem>

View file

@ -53,6 +53,7 @@ export function createMockVisualization(): jest.Mocked<Visualization> {
setDimension: jest.fn(),
removeDimension: jest.fn(),
getErrorMessages: jest.fn((_state, _frame) => undefined),
};
}
@ -92,6 +93,7 @@ export function createMockDatasource(id: string): DatasourceMock {
// this is an additional property which doesn't exist on real datasources
// but can be used to validate whether specific API mock functions are called
publicAPIMock,
getErrorMessages: jest.fn((_state) => undefined),
};
}

View file

@ -614,4 +614,178 @@ describe('IndexPattern Data Source', () => {
});
});
});
describe('#getErrorMessages', () => {
it('should detect a missing reference in a layer', () => {
const state = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'bytes',
},
},
},
},
currentIndexPatternId: '1',
};
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual({
shortMessage: 'Invalid reference.',
longMessage: 'Field "bytes" has an invalid reference.',
});
});
it('should detect and batch missing references in a layer', () => {
const state = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'bytes',
},
col2: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'memory',
},
},
},
},
currentIndexPatternId: '1',
};
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual({
shortMessage: 'Invalid references.',
longMessage: 'Fields "bytes", "memory" have invalid reference.',
});
});
it('should detect and batch missing references in multiple layers', () => {
const state = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'bytes',
},
col2: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'memory',
},
},
},
second: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'string',
isBucketed: false,
label: 'Foo',
operationType: 'count', // <= invalid
sourceField: 'source',
},
},
},
},
currentIndexPatternId: '1',
};
const messages = indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState);
expect(messages).toHaveLength(2);
expect(messages).toEqual([
{
shortMessage: 'Invalid references on Layer 1.',
longMessage: 'Layer 1 has invalid references in fields "bytes", "memory".',
},
{
shortMessage: 'Invalid reference on Layer 2.',
longMessage: 'Layer 2 has an invalid reference in field "source".',
},
]);
});
it('should return no errors if all references are satified', () => {
const state = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'document',
sourceField: 'bytes',
},
},
},
},
currentIndexPatternId: '1',
};
expect(
indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState)
).not.toBeDefined();
});
it('should return no errors with layers with no columns', () => {
const state: IndexPatternPrivateState = {
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
},
currentIndexPatternId: '1',
};
expect(indexPatternDatasource.getErrorMessages(state)).not.toBeDefined();
});
});
});

View file

@ -39,7 +39,12 @@ import {
getDatasourceSuggestionsForVisualizeField,
} from './indexpattern_suggestions';
import { isDraggedField, normalizeOperationDataType } from './utils';
import {
getInvalidFieldReferencesForLayer,
getInvalidReferences,
isDraggedField,
normalizeOperationDataType,
} from './utils';
import { LayerPanel } from './layerpanel';
import { IndexPatternColumn } from './operations';
import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types';
@ -49,6 +54,7 @@ import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/pub
import { deleteColumn } from './state_helpers';
import { Datasource, StateSetter } from '../index';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { FieldBasedIndexPatternColumn } from './operations/definitions/column_types';
import { Dragging } from '../drag_drop/providers';
export { OperationType, IndexPatternColumn } from './operations';
@ -335,6 +341,84 @@ export function getIndexPatternDatasource({
},
getDatasourceSuggestionsFromCurrentState,
getDatasourceSuggestionsForVisualizeField,
getErrorMessages(state) {
if (!state) {
return;
}
const invalidLayers = getInvalidReferences(state);
if (invalidLayers.length === 0) {
return;
}
const realIndex = Object.values(state.layers)
.map((layer, i) => {
const filteredIndex = invalidLayers.indexOf(layer);
if (filteredIndex > -1) {
return [filteredIndex, i + 1];
}
})
.filter(Boolean) as Array<[number, number]>;
const invalidFieldsPerLayer: string[][] = getInvalidFieldReferencesForLayer(
invalidLayers,
state.indexPatterns
);
const originalLayersList = Object.keys(state.layers);
return realIndex.map(([filteredIndex, layerIndex]) => {
const fieldsWithBrokenReferences: string[] = invalidFieldsPerLayer[filteredIndex].map(
(columnId) => {
const column = invalidLayers[filteredIndex].columns[
columnId
] as FieldBasedIndexPatternColumn;
return column.sourceField;
}
);
if (originalLayersList.length === 1) {
return {
shortMessage: i18n.translate(
'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer',
{
defaultMessage: 'Invalid {fields, plural, one {reference} other {references}}.',
values: {
fields: fieldsWithBrokenReferences.length,
},
}
),
longMessage: i18n.translate(
'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer',
{
defaultMessage: `{fieldsLength, plural, one {Field} other {Fields}} "{fields}" {fieldsLength, plural, one {has an} other {have}} invalid reference.`,
values: {
fields: fieldsWithBrokenReferences.join('", "'),
fieldsLength: fieldsWithBrokenReferences.length,
},
}
),
};
}
return {
shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', {
defaultMessage:
'Invalid {fieldsLength, plural, one {reference} other {references}} on Layer {layer}.',
values: {
layer: layerIndex,
fieldsLength: fieldsWithBrokenReferences.length,
},
}),
longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', {
defaultMessage: `Layer {layer} has {fieldsLength, plural, one {an invalid} other {invalid}} {fieldsLength, plural, one {reference} other {references}} in {fieldsLength, plural, one {field} other {fields}} "{fields}".`,
values: {
layer: layerIndex,
fields: fieldsWithBrokenReferences.join('", "'),
fieldsLength: fieldsWithBrokenReferences.length,
},
}),
};
});
},
};
return indexPatternDatasource;

View file

@ -5,7 +5,7 @@
*/
import { DataType } from '../types';
import { IndexPatternPrivateState, IndexPattern } from './types';
import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from './types';
import { DraggedField } from './indexpattern';
import {
BaseIndexPatternColumn,
@ -43,7 +43,11 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg
}
export function hasInvalidReference(state: IndexPatternPrivateState) {
return Object.values(state.layers).some((layer) => {
return getInvalidReferences(state).length > 0;
}
export function getInvalidReferences(state: IndexPatternPrivateState) {
return Object.values(state.layers).filter((layer) => {
return layer.columnOrder.some((columnId) => {
const column = layer.columns[columnId];
return (
@ -58,19 +62,39 @@ export function hasInvalidReference(state: IndexPatternPrivateState) {
});
}
export function getInvalidFieldReferencesForLayer(
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]
)
);
});
});
}
export function fieldIsInvalid(
sourceField: string | undefined,
operationType: OperationType | undefined,
indexPattern: IndexPattern
) {
const operationDefinition = operationType && operationDefinitionMap[operationType];
return Boolean(
sourceField &&
operationDefinition &&
!indexPattern.fields.some(
(field) =>
field.name === sourceField &&
operationDefinition.input === 'field' &&
operationDefinition?.input === 'field' &&
operationDefinition.getPossibleOperationForField(field) !== undefined
)
);

View file

@ -193,4 +193,28 @@ describe('metric_visualization', () => {
`);
});
});
describe('#getErrorMessages', () => {
it('returns undefined if no error is raised', () => {
const datasource: DatasourcePublicAPI = {
...createMockDatasource('l1').publicAPIMock,
getOperationForColumnId(_: string) {
return {
id: 'a',
dataType: 'number',
isBucketed: false,
label: 'shazm',
};
},
};
const frame = {
...mockFrame(),
datasourceLayers: { l1: datasource },
};
const error = metricVisualization.getErrorMessages(exampleState(), frame);
expect(error).not.toBeDefined();
});
});
});

View file

@ -115,4 +115,9 @@ export const metricVisualization: Visualization<State> = {
removeDimension({ prevState }) {
return { ...prevState, accessor: undefined };
},
getErrorMessages(state, frame) {
// Is it possible to break it?
return undefined;
},
};

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getPieVisualization } from './visualization';
import { PieVisualizationState } from './types';
import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks';
import { DatasourcePublicAPI, FramePublicAPI } from '../types';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
jest.mock('../id_generator');
const LAYER_ID = 'l1';
const pieVisualization = getPieVisualization({
paletteService: chartPluginMock.createPaletteRegistry(),
});
function exampleState(): PieVisualizationState {
return {
shape: 'pie',
layers: [
{
layerId: LAYER_ID,
groups: [],
metric: undefined,
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
nestedLegend: false,
},
],
};
}
function mockFrame(): FramePublicAPI {
return {
...createMockFramePublicAPI(),
addNewLayer: () => LAYER_ID,
datasourceLayers: {
[LAYER_ID]: createMockDatasource(LAYER_ID).publicAPIMock,
},
};
}
// Just a basic bootstrap here to kickstart the tests
describe('pie_visualization', () => {
describe('#getErrorMessages', () => {
it('returns undefined if no error is raised', () => {
const datasource: DatasourcePublicAPI = {
...createMockDatasource('l1').publicAPIMock,
getOperationForColumnId(_: string) {
return {
id: 'a',
dataType: 'number',
isBucketed: false,
label: 'shazm',
};
},
};
const frame = {
...mockFrame(),
datasourceLayers: { l1: datasource },
};
const error = pieVisualization.getErrorMessages(exampleState(), frame);
expect(error).not.toBeDefined();
});
});
});

View file

@ -233,4 +233,9 @@ export const getPieVisualization = ({
domElement
);
},
getErrorMessages(state, frame) {
// not possible to break it?
return undefined;
},
});

View file

@ -181,6 +181,7 @@ export interface Datasource<T = unknown, P = unknown> {
getDatasourceSuggestionsFromCurrentState: (state: T) => Array<DatasourceSuggestion<T>>;
getPublicAPI: (props: PublicAPIProps<T>) => DatasourcePublicAPI;
getErrorMessages: (state: T) => Array<{ shortMessage: string; longMessage: string }> | undefined;
/**
* uniqueLabels of dimensions exposed for aria-labels of dragged dimensions
*/
@ -571,6 +572,14 @@ export interface Visualization<T = unknown> {
state: T,
datasourceLayers: Record<string, DatasourcePublicAPI>
) => Ast | string | null;
/**
* The frame will call this function on all visualizations at few stages (pre-build/build error) in order
* to provide more context to the error and show it to the user
*/
getErrorMessages: (
state: T,
frame: FramePublicAPI
) => Array<{ shortMessage: string; longMessage: string }> | undefined;
}
export interface LensFilterEvent {

View file

@ -407,4 +407,219 @@ describe('xy_visualization', () => {
expect(ops.filter(filterOperations).map((x) => x.dataType)).toEqual(['number']);
});
});
describe('#getErrorMessages', () => {
it("should not return an error when there's only one dimension (X or Y)", () => {
expect(
xyVisualization.getErrorMessages(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: 'a',
accessors: [],
},
],
},
createMockFramePublicAPI()
)
).not.toBeDefined();
});
it("should not return an error when there's only one dimension on multiple layers (same axis everywhere)", () => {
expect(
xyVisualization.getErrorMessages(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: 'a',
accessors: [],
},
{
layerId: 'second',
seriesType: 'area',
xAccessor: 'a',
accessors: [],
},
],
},
createMockFramePublicAPI()
)
).not.toBeDefined();
});
it('should not return an error when mixing different valid configurations in multiple layers', () => {
expect(
xyVisualization.getErrorMessages(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: 'a',
accessors: ['a'],
},
{
layerId: 'second',
seriesType: 'area',
xAccessor: undefined,
accessors: ['a'],
splitAccessor: 'a',
},
],
},
createMockFramePublicAPI()
)
).not.toBeDefined();
});
it("should not return an error when there's only one splitAccessor dimension configured", () => {
expect(
xyVisualization.getErrorMessages(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: undefined,
accessors: [],
splitAccessor: 'a',
},
],
},
createMockFramePublicAPI()
)
).not.toBeDefined();
expect(
xyVisualization.getErrorMessages(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: undefined,
accessors: [],
splitAccessor: 'a',
},
{
layerId: 'second',
seriesType: 'area',
xAccessor: undefined,
accessors: [],
splitAccessor: 'a',
},
],
},
createMockFramePublicAPI()
)
).not.toBeDefined();
});
it('should return an error when there are multiple layers, one axis configured for each layer (but different axis from each other)', () => {
expect(
xyVisualization.getErrorMessages(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: 'a',
accessors: [],
},
{
layerId: 'second',
seriesType: 'area',
xAccessor: undefined,
accessors: ['a'],
},
],
},
createMockFramePublicAPI()
)
).toEqual([
{
shortMessage: 'Missing Vertical axis.',
longMessage: 'Layer 1 requires a field for the Vertical axis.',
},
]);
});
it('should return an error with batched messages for the same error with multiple layers', () => {
expect(
xyVisualization.getErrorMessages(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: 'a',
accessors: ['a'],
},
{
layerId: 'second',
seriesType: 'area',
xAccessor: undefined,
accessors: [],
splitAccessor: 'a',
},
{
layerId: 'third',
seriesType: 'area',
xAccessor: undefined,
accessors: [],
splitAccessor: 'a',
},
],
},
createMockFramePublicAPI()
)
).toEqual([
{
shortMessage: 'Missing Vertical axis.',
longMessage: 'Layers 2, 3 require a field for the Vertical axis.',
},
]);
});
it("should return an error when some layers are complete but other layers aren't", () => {
expect(
xyVisualization.getErrorMessages(
{
...exampleState(),
layers: [
{
layerId: 'first',
seriesType: 'area',
xAccessor: 'a',
accessors: [],
},
{
layerId: 'second',
seriesType: 'area',
xAccessor: 'a',
accessors: ['a'],
},
{
layerId: 'third',
seriesType: 'area',
xAccessor: 'a',
accessors: ['a'],
},
],
},
createMockFramePublicAPI()
)
).toEqual([
{
shortMessage: 'Missing Vertical axis.',
longMessage: 'Layer 1 requires a field for the Vertical axis.',
},
]);
});
});
});

View file

@ -174,29 +174,16 @@ export const getXyVisualization = ({
groups: [
{
groupId: 'x',
groupLabel: isHorizontal
? i18n.translate('xpack.lens.xyChart.verticalAxisLabel', {
defaultMessage: 'Vertical axis',
})
: i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', {
defaultMessage: 'Horizontal axis',
}),
groupLabel: getAxisName('x', { isHorizontal }),
accessors: layer.xAccessor ? [layer.xAccessor] : [],
filterOperations: isBucketed,
suggestedPriority: 1,
supportsMoreColumns: !layer.xAccessor,
required: !layer.seriesType.includes('percentage'),
dataTestSubj: 'lnsXY_xDimensionPanel',
},
{
groupId: 'y',
groupLabel: isHorizontal
? i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', {
defaultMessage: 'Horizontal axis',
})
: i18n.translate('xpack.lens.xyChart.verticalAxisLabel', {
defaultMessage: 'Vertical axis',
}),
groupLabel: getAxisName('y', { isHorizontal }),
accessors: sortedAccessors,
filterOperations: isNumericMetric,
supportsMoreColumns: true,
@ -309,8 +296,117 @@ export const getXyVisualization = ({
toExpression: (state, layers, attributes) =>
toExpression(state, layers, paletteService, attributes),
toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService),
getErrorMessages(state, frame) {
// Data error handling below here
const hasNoAccessors = ({ accessors }: LayerConfig) =>
accessors == null || accessors.length === 0;
const hasNoSplitAccessor = ({ splitAccessor, seriesType }: LayerConfig) =>
seriesType.includes('percentage') && splitAccessor == null;
const errors: Array<{
shortMessage: string;
longMessage: string;
}> = [];
// check if the layers in the state are compatible with this type of chart
if (state && state.layers.length > 1) {
// Order is important here: Y Axis is fundamental to exist to make it valid
const checks: Array<[string, (layer: LayerConfig) => boolean]> = [
['Y', hasNoAccessors],
['Break down', hasNoSplitAccessor],
];
// filter out those layers with no accessors at all
const filteredLayers = state.layers.filter(
({ accessors, xAccessor, splitAccessor }: LayerConfig) =>
accessors.length > 0 || xAccessor != null || splitAccessor != null
);
for (const [dimension, criteria] of checks) {
const result = validateLayersForDimension(dimension, filteredLayers, criteria);
if (!result.valid) {
errors.push(result.payload);
}
}
}
return errors.length ? errors : undefined;
},
});
function validateLayersForDimension(
dimension: string,
layers: LayerConfig[],
missingCriteria: (layer: LayerConfig) => boolean
):
| { valid: true }
| {
valid: false;
payload: { shortMessage: string; longMessage: string };
} {
// Multiple layers must be consistent:
// * either a dimension is missing in ALL of them
// * or should not miss on any
if (layers.every(missingCriteria) || !layers.some(missingCriteria)) {
return { valid: true };
}
// otherwise it's an error and it has to be reported
const layerMissingAccessors = layers.reduce((missing: number[], layer, i) => {
if (missingCriteria(layer)) {
missing.push(i);
}
return missing;
}, []);
return {
valid: false,
payload: getMessageIdsForDimension(dimension, layerMissingAccessors, isHorizontalChart(layers)),
};
}
function getAxisName(axis: 'x' | 'y', { isHorizontal }: { isHorizontal: boolean }) {
const vertical = i18n.translate('xpack.lens.xyChart.verticalAxisLabel', {
defaultMessage: 'Vertical axis',
});
const horizontal = i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', {
defaultMessage: 'Horizontal axis',
});
if (axis === 'x') {
return isHorizontal ? vertical : horizontal;
}
return isHorizontal ? horizontal : vertical;
}
// i18n ids cannot be dynamically generated, hence the function below
function getMessageIdsForDimension(dimension: string, layers: number[], isHorizontal: boolean) {
const layersList = layers.map((i: number) => i + 1).join(', ');
switch (dimension) {
case 'Break down':
return {
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataFailureSplitShort', {
defaultMessage: `Missing {axis}.`,
values: { axis: 'Break down by axis' },
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataFailureSplitLong', {
defaultMessage: `{layers, plural, one {Layer} other {Layers}} {layersList} {layers, plural, one {requires} other {require}} a field for the {axis}.`,
values: { layers: layers.length, layersList, axis: 'Break down by axis' },
}),
};
case 'Y':
return {
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataFailureYShort', {
defaultMessage: `Missing {axis}.`,
values: { axis: getAxisName('y', { isHorizontal }) },
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataFailureYLong', {
defaultMessage: `{layers, plural, one {Layer} other {Layers}} {layersList} {layers, plural, one {requires} other {require}} a field for the {axis}.`,
values: { layers: layers.length, layersList, axis: getAxisName('y', { isHorizontal }) },
}),
};
}
return { shortMessage: '', longMessage: '' };
}
function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig {
return {
layerId,