[Lens] Visualization validation and better error messages (#81439)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
89887984d8
commit
53ea09078f
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -233,4 +233,9 @@ export const getPieVisualization = ({
|
|||
domElement
|
||||
);
|
||||
},
|
||||
|
||||
getErrorMessages(state, frame) {
|
||||
// not possible to break it?
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue