diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 4bda56ca5087c..d82c7b092c38a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -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(); + }); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 1464ae6988a2d..e0f6ae31719ca 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -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 }, 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 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 }), 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 }; }, - 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 ], }; }, + + getErrorMessages(state, frame) { + return undefined; + }, }; + +function getDataSourceAndSortedColumns( + state: DatatableVisualizationState, + datasourceLayers: Record, + 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 }; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 8b0334ab98c14..28ad6c531e255 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -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; +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 63ee02ac0404d..201c91ee91676 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -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 = ( +
+ +
+ ); return (
- { - return ( -
- -
- ); - }} - /> + {!expression || hasError ? ( + onErrorMessage + ) : ( + { + return onErrorMessage; + }} + /> + )}
); }; @@ -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 ? ( ) : ( @@ -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; + ), + })); + + const validationErrors = validateDatasourceAndVisualization( + activeDatasourceId ? datasourceMap[activeDatasourceId] : null, + activeDatasourceId && currentDatasourceStates[activeDatasourceId]?.state, + currentVisualizationId ? visualizationMap[currentVisualizationId] : null, + currentVisualizationState, + frame + ); - return { suggestions: newSuggestions, currentStateExpression: newStateExpression }; + 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 && ( { 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( + '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( + {}} + 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( + {}} + 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); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index c4235a5514a54..e79060fb77329 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -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({ - 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 }) => ( + + {longMessage} + + )); + } else { + showExtraErrors = ( + + { + 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 }, + })} + + + ); + } + } + + return ( + + + + + + + + + + + + + {localState.configurationValidationError[0].longMessage} + + {showExtraErrors} + + ); + } + if (localState.expressionBuildError) { return ( @@ -338,10 +442,11 @@ export const InnerVisualizationWrapper = ({ defaultMessage="An error occurred in the expression" /> - {localState.expressionBuildError} + {localState.expressionBuildError[0].longMessage} ); } + return (
{ const visibleErrorMessage = getOriginalRequestErrorMessage(error) || errorMessage; + return ( diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 7e85ce5ecef71..5ab410a1c0af2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -53,6 +53,7 @@ export function createMockVisualization(): jest.Mocked { 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), }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 1609ff1dbc80e..a3f48b162475a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -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(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index edc984f5e8016..0d82292780808 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -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; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts index df6bde0ba1a35..d3d65617f2253 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts @@ -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 +) { + 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 ) ); diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts index 80c7a174b3264..5ee33f9b4b3dd 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts @@ -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(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index 77d189ce53d01..b75ac89d7e4d8 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -115,4 +115,9 @@ export const metricVisualization: Visualization = { removeDimension({ prevState }) { return { ...prevState, accessor: undefined }; }, + + getErrorMessages(state, frame) { + // Is it possible to break it? + return undefined; + }, }; diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts new file mode 100644 index 0000000000000..628d42d3de667 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts @@ -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(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 791480162b7fa..62e99396edbc7 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -233,4 +233,9 @@ export const getPieVisualization = ({ domElement ); }, + + getErrorMessages(state, frame) { + // not possible to break it? + return undefined; + }, }); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 6696a9328c837..27ab8f258bba8 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -181,6 +181,7 @@ export interface Datasource { getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; getPublicAPI: (props: PublicAPIProps) => 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 { state: T, datasourceLayers: Record ) => 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 { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 4dde646ab64a5..7c49afa53af3e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -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.', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index c41d8e977297b..c7f775586ca0d 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -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,