[Lens] Don't block render on missing field (#149262)

This commit is contained in:
Drew Tate 2023-02-02 18:09:43 -06:00 committed by GitHub
parent 4928487c32
commit be37fa1190
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 1582 additions and 739 deletions

View file

@ -7,7 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
import { SavedFieldNotFound, SavedFieldTypeInvalidForAgg } from '@kbn/kibana-utils-plugin/common';
import { SavedFieldTypeInvalidForAgg } from '@kbn/kibana-utils-plugin/common';
import { isNestedField, DataViewField } from '@kbn/data-views-plugin/common';
import { IAggConfig } from '../agg_config';
import { BaseParamType } from './base';
@ -43,6 +43,7 @@ export class FieldParamType extends BaseParamType {
this.scriptable = config.scriptable !== false;
this.filterField = config.filterField;
// TODO - are there any custom write methods that do a missing check?
if (!config.write) {
this.write = (aggConfig: IAggConfig, output: Record<string, any>) => {
const field = aggConfig.getField();
@ -59,24 +60,10 @@ export class FieldParamType extends BaseParamType {
);
}
if (field.type === KBN_FIELD_TYPES.MISSING) {
throw new SavedFieldNotFound(
i18n.translate(
'data.search.aggs.paramTypes.field.notFoundSavedFieldParameterErrorMessage',
{
defaultMessage:
'The field "{fieldParameter}" associated with this object no longer exists in the data view. Please use another field.',
values: {
fieldParameter: field.name,
},
}
)
);
}
const validField = this.getAvailableFields(aggConfig).find(
(f: any) => f.name === field.name
);
const validField =
field.type === KBN_FIELD_TYPES.MISSING // missing fields are always valid
? field
: this.getAvailableFields(aggConfig).find((f: any) => f.name === field.name);
if (!validField) {
throw new SavedFieldTypeInvalidForAgg(

View file

@ -46,7 +46,7 @@ describe('extract search response warnings', () => {
'Field [kubernetes.container.memory.available.bytes] of type' +
' [aggregate_metric_double] is not supported for aggregation [percentiles]',
},
text: 'The data you are seeing might be incomplete or wrong.',
text: 'The data might be incomplete or wrong.',
},
]);
});
@ -78,7 +78,7 @@ describe('extract search response warnings', () => {
type: 'shard_failure',
message: '77 of 79 shards failed',
reason: { type: 'generic_shard_warning' },
text: 'The data you are seeing might be incomplete or wrong.',
text: 'The data might be incomplete or wrong.',
},
]);
});
@ -95,7 +95,7 @@ describe('extract search response warnings', () => {
type: 'shard_failure',
message: '77 of 79 shards failed',
reason: { type: 'generic_shard_warning' },
text: 'The data you are seeing might be incomplete or wrong.',
text: 'The data might be incomplete or wrong.',
},
]);
});

View file

@ -39,7 +39,7 @@ export function extractWarnings(rawResponse: estypes.SearchResponse): SearchResp
);
const text = i18n.translate(
'data.search.searchSource.fetch.shardsFailedNotificationDescription',
{ defaultMessage: 'The data you are seeing might be incomplete or wrong.' }
{ defaultMessage: 'The data might be incomplete or wrong.' }
);
if (rawResponse._shards.failures) {

View file

@ -198,7 +198,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
type: 'shard_failure',
message: '2 of 4 shards failed',
reason: { reason: shardFailureReason, type: shardFailureType },
text: 'The data you are seeing might be incomplete or wrong.',
text: 'The data might be incomplete or wrong.',
},
]);
});

View file

@ -41,7 +41,10 @@ import {
createIndexPatternService,
} from '../data_views_service/service';
import { replaceIndexpattern } from '../state_management/lens_slice';
import { filterUserMessages, getApplicationUserMessages } from './get_application_user_messages';
import {
filterAndSortUserMessages,
getApplicationUserMessages,
} from './get_application_user_messages';
export type SaveProps = Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
@ -538,10 +541,10 @@ export function App({
);
const getUserMessages: UserMessagesGetter = (locationId, filterArgs) =>
filterUserMessages(
filterAndSortUserMessages(
[...userMessages, ...Object.values(additionalUserMessages)],
locationId,
filterArgs
filterArgs ?? {}
);
const addUserMessages: AddUserMessages = (messages) => {

View file

@ -14,7 +14,10 @@ import { shallow } from 'enzyme';
import { Visualization } from '..';
import { DataViewsState } from '../state_management';
import { Datasource, UserMessage } from '../types';
import { filterUserMessages, getApplicationUserMessages } from './get_application_user_messages';
import {
filterAndSortUserMessages,
getApplicationUserMessages,
} from './get_application_user_messages';
describe('application-level user messages', () => {
it('should generate error if vis type is not provided', () => {
@ -209,14 +212,14 @@ describe('filtering user messages', () => {
{
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'dimensionTrigger', dimensionId: dimensionId1 }],
displayLocations: [{ id: 'dimensionButton', dimensionId: dimensionId1 }],
shortMessage: 'Warning on dimension 1!',
longMessage: '',
},
{
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'dimensionTrigger', dimensionId: dimensionId2 }],
displayLocations: [{ id: 'dimensionButton', dimensionId: dimensionId2 }],
shortMessage: 'Warning on dimension 2!',
longMessage: '',
},
@ -251,7 +254,7 @@ describe('filtering user messages', () => {
];
it('filters by location', () => {
expect(filterUserMessages(userMessages, 'banner', {})).toMatchInlineSnapshot(`
expect(filterAndSortUserMessages(userMessages, 'banner', {})).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
@ -267,7 +270,7 @@ describe('filtering user messages', () => {
]
`);
expect(
filterUserMessages(userMessages, 'dimensionTrigger', {
filterAndSortUserMessages(userMessages, 'dimensionButton', {
dimensionId: dimensionId1,
})
).toMatchInlineSnapshot(`
@ -276,7 +279,7 @@ describe('filtering user messages', () => {
"displayLocations": Array [
Object {
"dimensionId": "foo",
"id": "dimensionTrigger",
"id": "dimensionButton",
},
],
"fixableInEditor": true,
@ -287,7 +290,7 @@ describe('filtering user messages', () => {
]
`);
expect(
filterUserMessages(userMessages, 'dimensionTrigger', {
filterAndSortUserMessages(userMessages, 'dimensionButton', {
dimensionId: dimensionId2,
})
).toMatchInlineSnapshot(`
@ -296,7 +299,7 @@ describe('filtering user messages', () => {
"displayLocations": Array [
Object {
"dimensionId": "baz",
"id": "dimensionTrigger",
"id": "dimensionButton",
},
],
"fixableInEditor": true,
@ -306,7 +309,7 @@ describe('filtering user messages', () => {
},
]
`);
expect(filterUserMessages(userMessages, ['visualization', 'visualizationInEditor'], {}))
expect(filterAndSortUserMessages(userMessages, ['visualization', 'visualizationInEditor'], {}))
.toMatchInlineSnapshot(`
Array [
Object {
@ -336,8 +339,8 @@ describe('filtering user messages', () => {
});
it('filters by severity', () => {
const warnings = filterUserMessages(userMessages, undefined, { severity: 'warning' });
const errors = filterUserMessages(userMessages, undefined, { severity: 'error' });
const warnings = filterAndSortUserMessages(userMessages, undefined, { severity: 'warning' });
const errors = filterAndSortUserMessages(userMessages, undefined, { severity: 'error' });
expect(warnings.length + errors.length).toBe(userMessages.length);
expect(warnings.every((message) => message.severity === 'warning'));
@ -346,7 +349,7 @@ describe('filtering user messages', () => {
it('filters by both', () => {
expect(
filterUserMessages(userMessages, ['visualization', 'visualizationOnEmbeddable'], {
filterAndSortUserMessages(userMessages, ['visualization', 'visualizationOnEmbeddable'], {
severity: 'warning',
})
).toMatchInlineSnapshot(`
@ -365,4 +368,19 @@ describe('filtering user messages', () => {
]
`);
});
it('sorts with warnings after errors', () => {
expect(
filterAndSortUserMessages(userMessages, undefined, {}).map((message) => message.severity)
).toMatchInlineSnapshot(`
Array [
"error",
"error",
"error",
"warning",
"warning",
"warning",
]
`);
});
});

View file

@ -180,7 +180,7 @@ function getMissingIndexPatternsErrors(
];
}
export const filterUserMessages = (
export const filterAndSortUserMessages = (
userMessages: UserMessage[],
locationId: UserMessagesDisplayLocationId | UserMessagesDisplayLocationId[] | undefined,
{ dimensionId, severity }: UserMessageFilters
@ -191,14 +191,14 @@ export const filterUserMessages = (
? [locationId]
: [];
return userMessages.filter((message) => {
const filteredMessages = userMessages.filter((message) => {
if (locationIds.length) {
const hasMatch = message.displayLocations.some((location) => {
if (!locationIds.includes(location.id)) {
return false;
}
if (location.id === 'dimensionTrigger' && location.dimensionId !== dimensionId) {
if (location.id === 'dimensionButton' && location.dimensionId !== dimensionId) {
return false;
}
@ -216,4 +216,16 @@ export const filterUserMessages = (
return true;
});
return filteredMessages.sort(bySeverity);
};
function bySeverity(a: UserMessage, b: UserMessage) {
if (a.severity === 'warning' && b.severity === 'error') {
return 1;
} else if (a.severity === 'error' && b.severity === 'warning') {
return -1;
} else {
return 0;
}
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 1a1 1 0 0 1 .707.293l4 4A1 1 0 0 1 15 6v5a1 1 0 0 1-.293.707l-4 4A1 1 0 0 1 10 16H5a1 1 0 0 1-.707-.293l-4-4A1 1 0 0 1 0 11V6a1 1 0 0 1 .293-.707l4-4A1 1 0 0 1 5 1h5ZM4.146 5.146a.5.5 0 0 1 .708 0L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708.708L8.207 8.5l2.647 2.646a.5.5 0 0 1-.708.708L7.5 9.207l-2.646 2.647a.5.5 0 0 1-.708-.708L6.793 8.5 4.146 5.854a.5.5 0 0 1 0-.708Z"/>
</svg>

After

Width:  |  Height:  |  Size: 495 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="m8.55 9.502.35-3.507a.905.905 0 1 0-1.8 0l.35 3.507a.552.552 0 0 0 1.1 0ZM9 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>
<path d="M8.864 1.496a1 1 0 0 0-1.728 0l-7 12A1 1 0 0 0 1 15h14a1 1 0 0 0 .864-1.504l-7-12ZM1 14 8 2l7 12H1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 327 B

View file

@ -52,3 +52,57 @@ Object {
},
}
`;
exports[`indexpattern_datasource utils getPrecisionErrorWarningMessages if has precision error and sorting is by count ascending, show fix action and switch to rare terms 1`] = `
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "warning",
"shortMessage": "This may be approximate depending on how the data is indexed. For more precise results, sort by rarity.",
}
`;
exports[`indexpattern_datasource utils getPrecisionErrorWarningMessages precision error warning with accuracy mode should other suggestions if accuracy mode already enabled 1`] = `
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "warning",
"shortMessage": "This might be an approximation. For more precise results, use Filters or increase the number of Top Values.",
}
`;
exports[`indexpattern_datasource utils getPrecisionErrorWarningMessages precision error warning with accuracy mode should show accuracy mode prompt if currently disabled 1`] = `
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "warning",
"shortMessage": "This might be an approximation. For more precise results, you can enable accuracy mode, but it increases the load on the Elasticsearch cluster.",
}
`;

View file

@ -341,8 +341,8 @@ export function DimensionEditor(props: DimensionEditorProps) {
});
const currentFieldIsInvalid = useMemo(
() => fieldIsInvalid(selectedColumn, currentIndexPattern),
[selectedColumn, currentIndexPattern]
() => fieldIsInvalid(state.layers[layerId], columnId, currentIndexPattern),
[state.layers, layerId, columnId, currentIndexPattern]
);
const shouldDisplayDots =

View file

@ -50,7 +50,7 @@ import { createMockedFullReference } from './operations/mocks';
import { cloneDeep } from 'lodash';
import { DatatableColumn } from '@kbn/expressions-plugin/common';
import { createMockFramePublicAPI } from '../../mocks';
import { filterUserMessages } from '../../app_plugin/get_application_user_messages';
import { filterAndSortUserMessages } from '../../app_plugin/get_application_user_messages';
jest.mock('./loader');
jest.mock('../../id_generator');
@ -3134,6 +3134,106 @@ describe('IndexPattern Data Source', () => {
`);
expect(getErrorMessages).toHaveBeenCalledTimes(2);
});
describe('dimension button error behavior', () => {
const state: FormBasedPrivateState = {
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {
col1: {
operationType: 'terms',
filter: {
query: '::: bad query that will mark column invalid',
language: 'kuery',
},
sourceField: 'op',
params: {
size: 5,
orderBy: { type: 'alphabetical' },
orderDirection: 'asc',
},
label: 'My Op',
dataType: 'string',
isBucketed: true,
} as TermsIndexPatternColumn,
},
},
},
currentIndexPatternId: '1',
};
it('should generate generic error if column invalid', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce([]);
const messages = FormBasedDatasource.getUserMessages(state, {
frame: { dataViews: { indexPatterns } } as unknown as FrameDatasourceAPI,
setState: () => {},
});
expect(messages.length).toBe(1);
expect(messages).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
],
"fixableInEditor": true,
"longMessage": <p>
Invalid configuration.
<br />
Click for more details.
</p>,
"severity": "error",
"shortMessage": "",
},
]
`);
});
it('should override generic error if operation generates something specific', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce([
{
displayLocations: [{ id: 'dimensionButton', dimensionId: 'col1' }],
message: 'specific error',
},
] as ReturnType<typeof getErrorMessages>);
const messages = FormBasedDatasource.getUserMessages(state, {
frame: { dataViews: { indexPatterns } } as unknown as FrameDatasourceAPI,
setState: () => {},
});
expect(messages.length).toBe(1);
expect(messages).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
],
"fixableInEditor": true,
"longMessage": <React.Fragment>
specific error
</React.Fragment>,
"severity": "error",
"shortMessage": "",
},
]
`);
});
});
});
describe('warning messages', () => {
@ -3266,7 +3366,9 @@ describe('IndexPattern Data Source', () => {
});
const extractTranslationIdsFromWarnings = (warnings: UserMessage[]) => {
const onlyWarnings = filterUserMessages(warnings, undefined, { severity: 'warning' });
const onlyWarnings = filterAndSortUserMessages(warnings, undefined, {
severity: 'warning',
});
return onlyWarnings.map(({ longMessage }) =>
isFragment(longMessage)
? (longMessage as ReactElement).props.children[0].props.id

View file

@ -100,6 +100,7 @@ import { isColumnOfType } from './operations/definitions/helpers';
import { LayerSettingsPanel } from './layer_settings';
import { FormBasedLayer } from '../..';
import { DimensionTrigger } from '../../shared_components/dimension_trigger';
import { filterAndSortUserMessages } from '../../app_plugin/get_application_user_messages';
export type { OperationType, GenericIndexPatternColumn } from './operations';
export { deleteColumn } from './operations';
@ -521,12 +522,6 @@ export function getFormBasedDatasource({
return columnLabelMap;
},
isValidColumn: (state, indexPatterns, layerId, columnId, dateRange) => {
const layer = state.layers[layerId];
return !isColumnInvalid(layer, columnId, indexPatterns[layer.indexPatternId], dateRange);
},
renderDimensionTrigger: (
domElement: Element,
props: DatasourceDimensionTriggerProps<FormBasedPrivateState>
@ -550,13 +545,7 @@ export function getFormBasedDatasource({
unifiedSearch,
}}
>
<DimensionTrigger
id={props.columnId}
label={formattedLabel}
isInvalid={props.invalid}
hideTooltip={props.hideTooltip}
invalidMessage={props.invalidMessage}
/>
<DimensionTrigger id={props.columnId} label={formattedLabel} />
</KibanaContextProvider>
</I18nProvider>
</KibanaThemeProvider>,
@ -842,8 +831,18 @@ export function getFormBasedDatasource({
data
);
const dimensionErrorMessages = getDimensionErrorMessages(state, (layerId, columnId) =>
this.isValidColumn(state, frameDatasourceAPI.dataViews.indexPatterns, layerId, columnId)
const dimensionErrorMessages = getInvalidDimensionErrorMessages(
state,
layerErrorMessages,
(layerId, columnId) => {
const layer = state.layers[layerId];
return !isColumnInvalid(
layer,
columnId,
frameDatasourceAPI.dataViews.indexPatterns[layer.indexPatternId],
frameDatasourceAPI.dateRange
);
}
);
const warningMessages = [
@ -853,13 +852,6 @@ export function getFormBasedDatasource({
state,
frameDatasourceAPI
) || []),
...getPrecisionErrorWarningMessages(
data.datatableUtilities,
state,
frameDatasourceAPI,
core.docLinks,
setState
),
].map((longMessage) => {
const message: UserMessage = {
severity: 'warning',
@ -871,6 +863,13 @@ export function getFormBasedDatasource({
return message;
}),
...getPrecisionErrorWarningMessages(
data.datatableUtilities,
state,
frameDatasourceAPI,
core.docLinks,
setState
),
];
return [...layerErrorMessages, ...dimensionErrorMessages, ...warningMessages];
@ -996,7 +995,10 @@ function getLayerErrorMessages(
const message: UserMessage = {
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
displayLocations:
typeof error !== 'string' && error.displayLocations
? error.displayLocations
: [{ id: 'visualization' }],
shortMessage: '',
longMessage:
typeof error === 'string' ? (
@ -1030,31 +1032,35 @@ function getLayerErrorMessages(
// Single layer case, no need to explain more
errorMessages = layerErrors[0]?.length ? layerErrors[0] : [];
} else {
// For multiple layers we will prepend each error with the layer number
errorMessages = layerErrors.flatMap((errors, index) => {
return errors.map((error) => {
const message: UserMessage = {
...error,
shortMessage: i18n.translate('xpack.lens.indexPattern.layerErrorWrapper', {
defaultMessage: 'Layer {position} error: {wrappedMessage}',
values: {
position: index + 1,
wrappedMessage: error.shortMessage,
},
}),
longMessage: (
<FormattedMessage
id="xpack.lens.indexPattern.layerErrorWrapper"
defaultMessage="Layer {position} error: {wrappedMessage}"
values={{
// we will prepend each error with the layer number
if (error.displayLocations.find((location) => location.id === 'visualization')) {
const message: UserMessage = {
...error,
shortMessage: i18n.translate('xpack.lens.indexPattern.layerErrorWrapper', {
defaultMessage: 'Layer {position} error: {wrappedMessage}',
values: {
position: index + 1,
wrappedMessage: <>{error.longMessage}</>,
}}
/>
),
};
wrappedMessage: error.shortMessage,
},
}),
longMessage: (
<FormattedMessage
id="xpack.lens.indexPattern.layerErrorWrapper"
defaultMessage="Layer {position} error: {wrappedMessage}"
values={{
position: index + 1,
wrappedMessage: <>{error.longMessage}</>,
}}
/>
),
};
return message;
return message;
}
return error;
});
});
}
@ -1062,8 +1068,9 @@ function getLayerErrorMessages(
return errorMessages;
}
function getDimensionErrorMessages(
function getInvalidDimensionErrorMessages(
state: FormBasedPrivateState,
currentErrorMessages: UserMessage[],
isValidColumn: (layerId: string, columnId: string) => boolean
) {
// generate messages for invalid columns
@ -1071,10 +1078,20 @@ function getDimensionErrorMessages(
.map((layerId) => {
const messages: UserMessage[] = [];
for (const columnId of Object.keys(state.layers[layerId].columns)) {
if (
filterAndSortUserMessages(currentErrorMessages, 'dimensionButton', {
dimensionId: columnId,
}).length > 0
) {
// there is already a more specific user message assigned to this column, so no need
// to add the default "is invalid" messaging
continue;
}
if (!isValidColumn(layerId, columnId)) {
messages.push({
severity: 'error',
displayLocations: [{ id: 'dimensionTrigger', dimensionId: columnId }],
displayLocations: [{ id: 'dimensionButton', dimensionId: columnId }],
fixableInEditor: true,
shortMessage: '',
longMessage: (

View file

@ -91,7 +91,7 @@ export const cardinalityOperation: OperationDefinition<
},
getErrorMessage: (layer, columnId, indexPattern) =>
combineErrorMessages([
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
getInvalidFieldMessage(layer, columnId, indexPattern),
getColumnReducedTimeRangeError(layer, columnId, indexPattern),
]),
isTransferable: (column, newIndexPattern) => {

View file

@ -90,7 +90,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
input: 'field',
getErrorMessage: (layer, columnId, indexPattern) =>
combineErrorMessages([
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
getInvalidFieldMessage(layer, columnId, indexPattern),
getColumnReducedTimeRangeError(layer, columnId, indexPattern),
]),
allowAsReference: true,

View file

@ -86,10 +86,7 @@ export const dateHistogramOperation: OperationDefinition<
operationParams: [{ name: 'interval', type: 'string', required: false }],
getErrorMessage: (layer, columnId, indexPattern) =>
[
...(getInvalidFieldMessage(
layer.columns[columnId] as FieldBasedIndexPatternColumn,
indexPattern
) || []),
...(getInvalidFieldMessage(layer, columnId, indexPattern) || []),
getMultipleDateHistogramsErrorMessage(layer, columnId) || '',
].filter(Boolean),
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {

View file

@ -0,0 +1,229 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`formula getErrorMessage returns an error if the field is missing 1`] = `
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 1,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
noField
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
},
]
`;
exports[`formula getErrorMessage returns an error if the field is missing 2`] = `
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 1,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
noField
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
},
]
`;
exports[`formula getErrorMessage returns an error if the field is missing 3`] = `
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 1,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
noField
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
},
]
`;
exports[`formula getErrorMessage returns an error if the field is missing 4`] = `
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 1,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
noField
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
},
]
`;
exports[`formula getErrorMessage returns an error with plural form correctly handled 1`] = `
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 2,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
noField
</strong>
,
</React.Fragment>
<React.Fragment>
<strong>
noField2
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
},
]
`;
exports[`formula getErrorMessage returns an error with plural form correctly handled 2`] = `
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 2,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
noField
</strong>
,
</React.Fragment>
<React.Fragment>
<strong>
noField2
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
},
]
`;

View file

@ -1081,7 +1081,7 @@ invalid: "
undefined,
operationDefinitionMap
)
).toEqual(['Field noField not found']);
).toMatchSnapshot();
}
});
@ -1097,7 +1097,7 @@ invalid: "
undefined,
operationDefinitionMap
)
).toEqual(['Fields noField, noField2 not found']);
).toMatchSnapshot();
}
});

View file

@ -6,6 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { uniqBy } from 'lodash';
import type { BaseIndexPatternColumn, OperationDefinition } from '..';
import type { ReferenceBasedIndexPatternColumn } from '../column_types';
import type { IndexPattern } from '../../../../../types';
@ -15,7 +16,7 @@ import { insertOrReplaceFormulaColumn } from './parse';
import { generateFormula } from './generate';
import { filterByVisibleOperation, nonNullable } from './util';
import { getManagedColumnsFrom } from '../../layer_helpers';
import { getFilter, isColumnFormatted } from '../helpers';
import { generateMissingFieldMessage, getFilter, isColumnFormatted } from '../helpers';
const defaultLabel = i18n.translate('xpack.lens.indexPattern.formulaLabel', {
defaultMessage: 'Formula',
@ -85,7 +86,11 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
if (errors.length) {
// remove duplicates
return Array.from(new Set(errors.map(({ message }) => message)));
return uniqBy(errors, ({ message }) => message).map(({ type, message, extraInfo }) =>
type === 'missingField' && extraInfo?.missingFields
? generateMissingFieldMessage(extraInfo.missingFields, columnId)
: message
);
}
const managedColumns = getManagedColumnsFrom(columnId, layer.columns);

View file

@ -117,9 +117,11 @@ type ErrorTypes = keyof ValidationErrors;
type ErrorValues<K extends ErrorTypes> = ValidationErrors[K]['type'];
export interface ErrorWrapper {
type?: ErrorTypes; // TODO - make this required?
message: string;
locations: TinymathLocation[];
severity?: 'error' | 'warning';
extraInfo?: { missingFields: string[] };
}
const DEFAULT_RETURN_TYPE = getTypeI18n('number');
@ -408,7 +410,7 @@ function getMessageFromId<K extends ErrorTypes>({
break;
}
return { message, locations };
return { type: messageId, message, locations };
}
export function tryToParse(
@ -501,16 +503,17 @@ function checkMissingVariableOrFunctions(
// need to check the arguments here: check only strings for now
if (missingVariables.length) {
missingErrors.push(
getMessageFromId({
missingErrors.push({
...getMessageFromId({
messageId: 'missingField',
values: {
variablesLength: missingVariables.length,
variablesList: missingVariables.map(({ value }) => value).join(', '),
},
locations: missingVariables.map(({ location }) => location),
})
);
}),
extraInfo: { missingFields: missingVariables.map(({ value }) => value) },
});
}
const invalidVariableErrors = checkVariableEdgeCases(
ast,

View file

@ -6,35 +6,81 @@
*/
import { createMockedIndexPattern } from '../../mocks';
import type { FormBasedLayer } from '../../types';
import type { GenericIndexPatternColumn } from './column_types';
import { getInvalidFieldMessage } from './helpers';
import type { TermsIndexPatternColumn } from './terms';
describe('helpers', () => {
const columnId = 'column_id';
const getLayerWithColumn = (column: GenericIndexPatternColumn) =>
({
columnOrder: [columnId],
indexPatternId: '',
columns: {
[columnId]: column,
},
} as FormBasedLayer);
describe('getInvalidFieldMessage', () => {
it('return an error if a field was removed', () => {
const messages = getInvalidFieldMessage(
{
getLayerWithColumn({
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'count',
sourceField: 'NoBytes', // <= invalid
},
}),
columnId,
createMockedIndexPattern()
);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual('Field NoBytes was not found');
expect(messages![0]).toMatchInlineSnapshot(`
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "column_id",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 1,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
NoBytes
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
}
`);
});
it('returns an error if a field is the wrong type', () => {
const messages = getInvalidFieldMessage(
{
getLayerWithColumn({
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'average',
sourceField: 'timestamp', // <= invalid type for average
},
}),
columnId,
createMockedIndexPattern()
);
expect(messages).toHaveLength(1);
@ -43,7 +89,7 @@ describe('helpers', () => {
it('returns an error if one field amongst multiples does not exist', () => {
const messages = getInvalidFieldMessage(
{
getLayerWithColumn({
dataType: 'number',
isBucketed: false,
label: 'Foo',
@ -52,16 +98,49 @@ describe('helpers', () => {
params: {
secondaryFields: ['NoBytes'], // <= field does not exist
},
} as TermsIndexPatternColumn,
} as TermsIndexPatternColumn),
columnId,
createMockedIndexPattern()
);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual('Field NoBytes was not found');
expect(messages![0]).toMatchInlineSnapshot(`
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "column_id",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 1,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
NoBytes
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
}
`);
});
it('returns an error if multiple fields do not exist', () => {
const messages = getInvalidFieldMessage(
{
getLayerWithColumn({
dataType: 'number',
isBucketed: false,
label: 'Foo',
@ -70,16 +149,55 @@ describe('helpers', () => {
params: {
secondaryFields: ['NoBytes'], // <= field does not exist
},
} as TermsIndexPatternColumn,
} as TermsIndexPatternColumn),
columnId,
createMockedIndexPattern()
);
expect(messages).toHaveLength(1);
expect(messages![0]).toEqual('Fields NotExisting, NoBytes were not found');
expect(messages![0]).toMatchInlineSnapshot(`
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "column_id",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 2,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
NotExisting
</strong>
,
</React.Fragment>
<React.Fragment>
<strong>
NoBytes
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
}
`);
});
it('returns an error if one field amongst multiples has the wrong type', () => {
const messages = getInvalidFieldMessage(
{
getLayerWithColumn({
dataType: 'number',
isBucketed: false,
label: 'Foo',
@ -88,7 +206,8 @@ describe('helpers', () => {
params: {
secondaryFields: ['timestamp'], // <= invalid type
},
} as TermsIndexPatternColumn,
} as TermsIndexPatternColumn),
columnId,
createMockedIndexPattern()
);
expect(messages).toHaveLength(1);
@ -97,7 +216,7 @@ describe('helpers', () => {
it('returns an error if multiple fields are of the wrong type', () => {
const messages = getInvalidFieldMessage(
{
getLayerWithColumn({
dataType: 'number',
isBucketed: false,
label: 'Foo',
@ -106,7 +225,8 @@ describe('helpers', () => {
params: {
secondaryFields: ['timestamp'], // <= invalid type
},
} as TermsIndexPatternColumn,
} as TermsIndexPatternColumn),
columnId,
createMockedIndexPattern()
);
expect(messages).toHaveLength(1);
@ -115,13 +235,14 @@ describe('helpers', () => {
it('returns no message if all fields are matching', () => {
const messages = getInvalidFieldMessage(
{
getLayerWithColumn({
dataType: 'number',
isBucketed: false,
label: 'Foo',
operationType: 'average',
sourceField: 'bytes',
},
}),
columnId,
createMockedIndexPattern()
);
expect(messages).toBeUndefined();

View file

@ -6,8 +6,14 @@
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { IndexPattern, IndexPatternField } from '../../../../types';
import { GenericIndexPatternColumn, operationDefinitionMap } from '.';
import {
type FieldBasedOperationErrorMessage,
type GenericIndexPatternColumn,
operationDefinitionMap,
} from '.';
import {
FieldBasedIndexPatternColumn,
FormattedIndexPatternColumn,
@ -17,12 +23,15 @@ import type { FormBasedLayer } from '../../types';
import { hasField } from '../../pure_utils';
export function getInvalidFieldMessage(
column: FieldBasedIndexPatternColumn,
layer: FormBasedLayer,
columnId: string,
indexPattern?: IndexPattern
) {
): FieldBasedOperationErrorMessage[] | undefined {
if (!indexPattern) {
return;
}
const column = layer.columns[columnId] as FieldBasedIndexPatternColumn;
const { operationType } = column;
const operationDefinition = operationType ? operationDefinitionMap[operationType] : undefined;
const fieldNames =
@ -55,18 +64,11 @@ export function getInvalidFieldMessage(
// Missing fields have priority over wrong type
// This has been moved as some transferable checks also perform exist checks internally and fail eventually
// but that would make type mismatch error appear in place of missing fields scenarios
const missingFields = fields.map((field, i) => (field ? null : fieldNames[i])).filter(Boolean);
const missingFields = fields
.map((field, i) => (field ? null : fieldNames[i]))
.filter(Boolean) as string[];
if (missingFields.length) {
return [
i18n.translate('xpack.lens.indexPattern.fieldsNotFound', {
defaultMessage:
'{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found',
values: {
count: missingFields.length,
missingFields: missingFields.join(', '),
},
}),
];
return [generateMissingFieldMessage(missingFields, columnId)];
}
if (isWrongType) {
// as fallback show all the fields as invalid?
@ -88,10 +90,40 @@ export function getInvalidFieldMessage(
return undefined;
}
export const generateMissingFieldMessage = (
missingFields: string[],
columnId: string
): FieldBasedOperationErrorMessage => ({
message: (
<FormattedMessage
id="xpack.lens.indexPattern.fieldsNotFound"
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
values={{
count: missingFields.length,
missingFields: (
<>
{missingFields.map((field, index) => (
<>
<strong>{field}</strong>
{index + 1 === missingFields.length ? '' : ', '}
</>
))}
</>
),
}}
/>
),
displayLocations: [
{ id: 'toolbar' },
{ id: 'dimensionButton', dimensionId: columnId },
{ id: 'embeddableBadge' },
],
});
export function combineErrorMessages(
errorMessages: Array<string[] | undefined>
): string[] | undefined {
const messages = (errorMessages.filter(Boolean) as string[][]).flat();
errorMessages: Array<FieldBasedOperationErrorMessage[] | undefined>
): FieldBasedOperationErrorMessage[] | undefined {
const messages = (errorMessages.filter(Boolean) as FieldBasedOperationErrorMessage[][]).flat();
return messages.length ? messages : undefined;
}

View file

@ -49,12 +49,13 @@ import { countOperation } from './count';
import { mathOperation, formulaOperation } from './formula';
import { staticValueOperation } from './static_value';
import { lastValueOperation } from './last_value';
import {
import type {
FrameDatasourceAPI,
IndexPattern,
IndexPatternField,
OperationMetadata,
ParamEditorCustomProps,
UserMessage,
} from '../../../../types';
import type {
BaseIndexPatternColumn,
@ -312,23 +313,7 @@ interface BaseOperationDefinitionProps<
indexPattern: IndexPattern,
dateRange?: DateRange,
operationDefinitionMap?: Record<string, GenericOperationDefinition>
) =>
| Array<
| string
| {
message: string;
fixAction?: {
label: string;
newState: (
data: DataPublicPluginStart,
core: CoreStart,
frame: FrameDatasourceAPI,
layerId: string
) => Promise<FormBasedLayer>;
};
}
>
| undefined;
) => FieldBasedOperationErrorMessage[] | undefined;
/*
* Flag whether this operation can be scaled by time unit if a date histogram is available.
@ -468,6 +453,21 @@ interface FilterParams {
lucene?: string;
}
export type FieldBasedOperationErrorMessage =
| {
message: string | React.ReactNode;
displayLocations?: UserMessage['displayLocations'];
fixAction?: {
label: string;
newState: (
data: DataPublicPluginStart,
core: CoreStart,
frame: FrameDatasourceAPI,
layerId: string
) => Promise<FormBasedLayer>;
};
}
| string;
interface FieldlessOperationDefinition<C extends BaseIndexPatternColumn, P = {}> {
input: 'none';
@ -571,23 +571,7 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn, P = {}
columnId: string,
indexPattern: IndexPattern,
operationDefinitionMap?: Record<string, GenericOperationDefinition>
) =>
| Array<
| string
| {
message: string;
fixAction?: {
label: string;
newState: (
data: DataPublicPluginStart,
core: CoreStart,
frame: FrameDatasourceAPI,
layerId: string
) => Promise<FormBasedLayer>;
};
}
>
| undefined;
) => FieldBasedOperationErrorMessage[] | undefined;
}
export interface RequiredReference {

View file

@ -917,9 +917,42 @@ describe('last_value', () => {
} as LastValueIndexPatternColumn,
},
};
expect(lastValueOperation.getErrorMessage!(errorLayer, 'col1', indexPattern)).toEqual([
'Field notExisting was not found',
]);
expect(lastValueOperation.getErrorMessage!(errorLayer, 'col1', indexPattern))
.toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 1,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
notExisting
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
},
]
`);
});
it('shows error message if the sortField does not exist in index pattern', () => {
@ -935,10 +968,58 @@ describe('last_value', () => {
} as LastValueIndexPatternColumn,
},
};
expect(lastValueOperation.getErrorMessage!(errorLayer, 'col1', indexPattern)).toEqual([
'Field notExisting was not found',
]);
expect(lastValueOperation.getErrorMessage!(errorLayer, 'col1', indexPattern))
.toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "visualization",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="Sort field {sortField} was not found."
id="xpack.lens.indexPattern.lastValue.sortFieldNotFound"
values={
Object {
"sortField": <strong>
notExisting
</strong>,
}
}
/>,
},
]
`);
});
it('shows both messages if neither field exists in index pattern', () => {
errorLayer = {
...errorLayer,
columns: {
col1: {
...errorLayer.columns.col1,
sourceField: 'notExisting1',
params: {
...(errorLayer.columns.col1 as LastValueIndexPatternColumn).params,
sortField: 'notExisting2',
},
} as LastValueIndexPatternColumn,
},
};
expect(lastValueOperation.getErrorMessage!(errorLayer, 'col1', indexPattern)).toHaveLength(2);
});
it('shows error message if the sourceField is of unsupported type', () => {
indexPattern.getFieldByName('start_date')!.type = 'unsupported_type';
errorLayer = {

View file

@ -18,7 +18,8 @@ import {
} from '@elastic/eui';
import { AggFunctionsMapping } from '@kbn/data-plugin/public';
import { buildExpressionFunction } from '@kbn/expressions-plugin/public';
import { OperationDefinition } from '.';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FieldBasedOperationErrorMessage, OperationDefinition } from '.';
import { FieldBasedIndexPatternColumn, ValueFormatConfig } from './column_types';
import type { IndexPatternField, IndexPattern } from '../../../../types';
import { DataType } from '../../../../types';
@ -62,16 +63,33 @@ const supportedTypes = new Set([
'date_range',
]);
export function getInvalidSortFieldMessage(sortField: string, indexPattern?: IndexPattern) {
function getInvalidSortFieldMessage(
sortField: string,
columnId: string,
indexPattern?: IndexPattern
): FieldBasedOperationErrorMessage | undefined {
if (!indexPattern) {
return;
}
const field = indexPattern.getFieldByName(sortField);
if (!field) {
return i18n.translate('xpack.lens.indexPattern.lastValue.sortFieldNotFound', {
defaultMessage: 'Field {invalidField} was not found',
values: { invalidField: sortField },
});
return {
message: (
<FormattedMessage
id="xpack.lens.indexPattern.lastValue.sortFieldNotFound"
defaultMessage="Sort field {sortField} was not found."
values={{
sortField: <strong>{sortField}</strong>,
}}
/>
),
displayLocations: [
{ id: 'toolbar' },
{ id: 'dimensionButton', dimensionId: columnId },
{ id: 'visualization' },
{ id: 'embeddableBadge' },
],
};
}
if (field.type !== 'date') {
return i18n.translate('xpack.lens.indexPattern.lastValue.invalidTypeSortField', {
@ -201,18 +219,22 @@ export const lastValueOperation: OperationDefinition<
},
getErrorMessage(layer, columnId, indexPattern) {
const column = layer.columns[columnId] as LastValueIndexPatternColumn;
let errorMessages: string[] = [];
const invalidSourceFieldMessage = getInvalidFieldMessage(column, indexPattern);
const errorMessages: FieldBasedOperationErrorMessage[] = [];
const invalidSourceFieldMessage = getInvalidFieldMessage(layer, columnId, indexPattern);
if (invalidSourceFieldMessage) {
errorMessages.push(...invalidSourceFieldMessage);
}
const invalidSortFieldMessage = getInvalidSortFieldMessage(
column.params.sortField,
columnId,
indexPattern
);
if (invalidSourceFieldMessage) {
errorMessages = [...invalidSourceFieldMessage];
}
if (invalidSortFieldMessage) {
errorMessages = [invalidSortFieldMessage];
errorMessages.push(invalidSortFieldMessage);
}
errorMessages.push(...(getColumnReducedTimeRangeError(layer, columnId, indexPattern) || []));
return errorMessages.length ? errorMessages : undefined;
},
@ -316,6 +338,7 @@ export const lastValueOperation: OperationDefinition<
const dateFields = getDateFields(indexPattern);
const isSortFieldInvalid = !!getInvalidSortFieldMessage(
currentColumn.params.sortField,
'',
indexPattern
);

View file

@ -210,10 +210,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({
getErrorMessage: (layer, columnId, indexPattern) =>
combineErrorMessages([
getInvalidFieldMessage(
layer.columns[columnId] as FieldBasedIndexPatternColumn,
indexPattern
),
getInvalidFieldMessage(layer, columnId, indexPattern),
getColumnReducedTimeRangeError(layer, columnId, indexPattern),
]),
filterable: true,

View file

@ -288,7 +288,7 @@ export const percentileOperation: OperationDefinition<
},
getErrorMessage: (layer, columnId, indexPattern) =>
combineErrorMessages([
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
getInvalidFieldMessage(layer, columnId, indexPattern),
getColumnReducedTimeRangeError(layer, columnId, indexPattern),
]),
paramEditor: function PercentileParamEditor({

View file

@ -166,7 +166,7 @@ export const percentileRanksOperation: OperationDefinition<
},
getErrorMessage: (layer, columnId, indexPattern) =>
combineErrorMessages([
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
getInvalidFieldMessage(layer, columnId, indexPattern),
getColumnReducedTimeRangeError(layer, columnId, indexPattern),
]),
paramEditor: function PercentileParamEditor({

View file

@ -83,7 +83,7 @@ export const rangeOperation: OperationDefinition<
priority: 4, // Higher than terms, so numbers get histogram
input: 'field',
getErrorMessage: (layer, columnId, indexPattern) =>
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
getInvalidFieldMessage(layer, columnId, indexPattern),
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
type === 'number' &&

View file

@ -29,11 +29,7 @@ import { DOCUMENT_FIELD_NAME } from '../../../../../../common';
import { insertOrReplaceColumn, updateColumnParam, updateDefaultLabels } from '../../layer_helpers';
import type { DataType, OperationMetadata } from '../../../../../types';
import { OperationDefinition } from '..';
import {
FieldBasedIndexPatternColumn,
GenericIndexPatternColumn,
IncompleteColumn,
} from '../column_types';
import { GenericIndexPatternColumn, IncompleteColumn } from '../column_types';
import { ValuesInput } from './values_input';
import { getInvalidFieldMessage, isColumn } from '../helpers';
import { FieldInputs, getInputFieldErrorMessage, MAX_MULTI_FIELDS_SIZE } from './field_inputs';
@ -187,10 +183,7 @@ export const termsOperation: OperationDefinition<
},
getErrorMessage: (layer, columnId, indexPattern) => {
const messages = [
...(getInvalidFieldMessage(
layer.columns[columnId] as FieldBasedIndexPatternColumn,
indexPattern
) || []),
...(getInvalidFieldMessage(layer, columnId, indexPattern) || []),
getDisallowedTermsMessage(layer, columnId, indexPattern) || '',
getMultiTermsScriptedFieldErrorMessage(layer, columnId, indexPattern) || '',
].filter(Boolean);

View file

@ -2636,9 +2636,41 @@ describe('terms', () => {
} as TermsIndexPatternColumn,
},
};
expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([
'Field notExisting was not found',
]);
expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "toolbar",
},
Object {
"dimensionId": "col1",
"id": "dimensionButton",
},
Object {
"id": "embeddableBadge",
},
],
"message": <FormattedMessage
defaultMessage="{count, plural, one {Field} other {Fields}} {missingFields} {count, plural, one {was} other {were}} not found."
id="xpack.lens.indexPattern.fieldsNotFound"
values={
Object {
"count": 1,
"missingFields": <React.Fragment>
<React.Fragment>
<strong>
notExisting
</strong>
</React.Fragment>
</React.Fragment>,
}
}
/>,
},
]
`);
});
it('return no error for scripted field when in single mode', () => {

View file

@ -22,11 +22,12 @@ import type {
import {
operationDefinitionMap,
operationDefinitions,
OperationType,
RequiredReference,
OperationDefinition,
GenericOperationDefinition,
TermsIndexPatternColumn,
type OperationType,
type RequiredReference,
type OperationDefinition,
type GenericOperationDefinition,
type TermsIndexPatternColumn,
type FieldBasedOperationErrorMessage,
} from './definitions';
import type { DataViewDragDropOperation, FormBasedLayer, FormBasedPrivateState } from '../types';
import { getSortScoreByPriorityForField } from './operations';
@ -1536,6 +1537,10 @@ export function updateLayerIndexPattern(
};
}
type LayerErrorMessage = FieldBasedOperationErrorMessage & {
fixAction: DatasourceFixAction<FormBasedPrivateState>;
};
/**
* Collects all errors from the columns in the layer, for display in the workspace. This includes:
*
@ -1552,15 +1557,7 @@ export function getErrorMessages(
layerId: string,
core: CoreStart,
data: DataPublicPluginStart
):
| Array<
| string
| {
message: string;
fixAction?: DatasourceFixAction<FormBasedPrivateState>;
}
>
| undefined {
): LayerErrorMessage[] | undefined {
const columns = Object.entries(layer.columns);
const visibleManagedReferences = columns.filter(
([columnId, column]) =>
@ -1608,13 +1605,7 @@ export function getErrorMessages(
};
})
// remove the undefined values
.filter((v) => v != null) as Array<
| string
| {
message: string;
fixAction?: DatasourceFixAction<FormBasedPrivateState>;
}
>;
.filter((v) => v != null) as LayerErrorMessage[];
return errors.length ? errors : undefined;
}

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import type { FormBasedLayer } from './types';
import type { IndexPattern } from '../../types';
import type { FieldBasedOperationErrorMessage } from './operations/definitions';
export const reducedTimeRangeOptions = [
{
@ -56,7 +57,7 @@ export function getColumnReducedTimeRangeError(
layer: FormBasedLayer,
columnId: string,
indexPattern: IndexPattern
): string[] | undefined {
): FieldBasedOperationErrorMessage[] | undefined {
const currentColumn = layer.columns[columnId];
if (!currentColumn.reducedTimeRange) {
return;
@ -65,7 +66,7 @@ export function getColumnReducedTimeRangeError(
(column) => column.operationType === 'date_histogram'
);
const hasTimeField = Boolean(indexPattern.timeFieldName);
return [
const errors: FieldBasedOperationErrorMessage[] = [
hasDateHistogram &&
i18n.translate('xpack.lens.indexPattern.reducedTimeRangeWithDateHistogram', {
defaultMessage:
@ -82,5 +83,7 @@ export function getColumnReducedTimeRangeError(
column: currentColumn.label,
},
}),
].filter(Boolean) as string[];
].filter(Boolean) as FieldBasedOperationErrorMessage[];
return errors;
}

View file

@ -121,7 +121,9 @@ describe('indexpattern_datasource utils', () => {
expect(warningMessages).toHaveLength(1);
const instance = mountWithIntl(<div>{warningMessages[0]!}</div>);
expect({ ...warningMessages[0], longMessage: '' }).toMatchSnapshot();
const instance = mountWithIntl(<div>{warningMessages[0].longMessage}</div>);
const enableAccuracyButton = instance.find(enableAccuracyButtonSelector);
@ -146,7 +148,9 @@ describe('indexpattern_datasource utils', () => {
expect(warningMessages).toHaveLength(1);
const instance = shallow(<div>{warningMessages[0]!}</div>);
expect({ ...warningMessages[0], longMessage: '' }).toMatchSnapshot();
const instance = shallow(<div>{warningMessages[0].longMessage}</div>);
expect(instance.exists(enableAccuracyButtonSelector)).toBeFalsy();
@ -185,7 +189,8 @@ describe('indexpattern_datasource utils', () => {
);
expect(warnings).toHaveLength(1);
const DummyComponent = () => <>{warnings[0]}</>;
expect({ ...warnings[0], longMessage: '' }).toMatchSnapshot();
const DummyComponent = () => <>{warnings[0].longMessage}</>;
const warningUi = shallow(<DummyComponent />);
warningUi.find(EuiLink).simulate('click');
const stateSetter = setState.mock.calls[0][0];

View file

@ -78,6 +78,8 @@ export function isColumnInvalid(
operationDefinitionMap
);
// it looks like this is just a back-stop since we prevent
// invalid filters from being set at the UI level
const filterHasError = column.filter ? !isQueryValid(column.filter, indexPattern) : false;
return (
@ -107,28 +109,91 @@ function getReferencesErrors(
}
export function fieldIsInvalid(
column: GenericIndexPatternColumn | undefined,
layer: FormBasedLayer,
columnId: string,
indexPattern: IndexPattern
) {
const column = layer.columns[columnId];
if (!column || !hasField(column)) {
return false;
}
return !!getInvalidFieldMessage(column, indexPattern)?.length;
return !!getInvalidFieldMessage(layer, columnId, indexPattern)?.length;
}
const accuracyModeDisabledWarning = (
columnName: string,
docLink: string,
columnId: string,
enableAccuracyMode: () => void
) => (
<>
): UserMessage => ({
severity: 'warning',
displayLocations: [{ id: 'toolbar' }, { id: 'dimensionButton', dimensionId: columnId }],
fixableInEditor: true,
shortMessage: i18n.translate(
'xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled.shortMessage',
{
defaultMessage:
'This might be an approximation. For more precise results, you can enable accuracy mode, but it increases the load on the Elasticsearch cluster.',
}
),
longMessage: (
<>
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled"
defaultMessage="{name} might be an approximation. You can enable accuracy mode for more precise results, but note that it increases the load on the Elasticsearch cluster."
values={{
name: <strong>{columnName}</strong>,
}}
/>
<EuiSpacer size="s" />
<EuiLink data-test-subj="lnsPrecisionWarningEnableAccuracy" onClick={enableAccuracyMode}>
{i18n.translate('xpack.lens.indexPattern.enableAccuracyMode', {
defaultMessage: 'Enable accuracy mode',
})}
</EuiLink>
</>
),
});
const accuracyModeEnabledWarning = (
columnName: string,
columnId: string,
docLink: string
): UserMessage => ({
severity: 'warning',
displayLocations: [{ id: 'toolbar' }, { id: 'dimensionButton', dimensionId: columnId }],
fixableInEditor: true,
shortMessage: i18n.translate(
'xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled.shortMessage',
{
defaultMessage:
'This might be an approximation. For more precise results, use Filters or increase the number of Top Values.',
}
),
longMessage: (
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled"
defaultMessage="{name} might be an approximation. You can enable accuracy mode for more precise results, but note that it increases the load on the Elasticsearch cluster. {learnMoreLink}"
id="xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled"
defaultMessage="{name} might be an approximation. For more precise results, try increasing the number of {topValues} or using {filters} instead. {learnMoreLink}"
values={{
name: <strong>{columnName}</strong>,
topValues: (
<strong>
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.topValues"
defaultMessage="Top Values"
/>
</strong>
),
filters: (
<strong>
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.filters"
defaultMessage="Filters"
/>
</strong>
),
learnMoreLink: (
<EuiLink href={docLink} color="text" target="_blank" external={true}>
<EuiLink href={docLink} target="_blank" external={true}>
<FormattedMessage
defaultMessage="Learn more."
id="xpack.lens.indexPattern.precisionErrorWarning.link"
@ -137,48 +202,8 @@ const accuracyModeDisabledWarning = (
),
}}
/>
<EuiSpacer size="s" />
<EuiLink data-test-subj="lnsPrecisionWarningEnableAccuracy" onClick={enableAccuracyMode}>
{i18n.translate('xpack.lens.indexPattern.enableAccuracyMode', {
defaultMessage: 'Enable accuracy mode',
})}
</EuiLink>
</>
);
const accuracyModeEnabledWarning = (columnName: string, docLink: string) => (
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled"
defaultMessage="{name} might be an approximation. For more precise results, try increasing the number of {topValues} or using {filters} instead. {learnMoreLink}"
values={{
name: <strong>{columnName}</strong>,
topValues: (
<strong>
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.topValues"
defaultMessage="top values"
/>
</strong>
),
filters: (
<strong>
<FormattedMessage
id="xpack.lens.indexPattern.precisionErrorWarning.filters"
defaultMessage="filters"
/>
</strong>
),
learnMoreLink: (
<EuiLink href={docLink} color="text" target="_blank" external={true}>
<FormattedMessage
defaultMessage="Learn more."
id="xpack.lens.indexPattern.precisionErrorWarning.link"
/>
</EuiLink>
),
}}
/>
);
),
});
export function getShardFailuresWarningMessages(
state: FormBasedPersistedState,
@ -268,7 +293,7 @@ export function getPrecisionErrorWarningMessages(
docLinks: DocLinksStart,
setState: StateSetter<FormBasedPrivateState>
) {
const warningMessages: React.ReactNode[] = [];
const warningMessages: UserMessage[] = [];
if (state && activeData) {
Object.entries(activeData)
@ -310,55 +335,12 @@ export function getPrecisionErrorWarningMessages(
) {
warningMessages.push(
currentColumn.params.accuracyMode
? accuracyModeEnabledWarning(column.name, docLinks.links.aggs.terms_doc_count_error)
: accuracyModeDisabledWarning(
? accuracyModeEnabledWarning(
column.name,
docLinks.links.aggs.terms_doc_count_error,
() => {
setState((prevState) =>
mergeLayer({
state: prevState,
layerId,
newLayer: updateDefaultLabels(
updateColumnParam({
layer: currentLayer,
columnId: column.id,
paramName: 'accuracyMode',
value: true,
}),
indexPattern
),
})
);
}
column.id,
docLinks.links.aggs.terms_doc_count_error
)
);
} else {
warningMessages.push(
<>
<FormattedMessage
id="xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning"
defaultMessage="{name} for this visualization may be approximate due to how the data is indexed. Try sorting by rarity instead of ascending count of records. To learn more about this limit, {link}."
values={{
name: <strong>{column.name}</strong>,
link: (
<EuiLink
href={docLinks.links.aggs.rare_terms}
color="text"
target="_blank"
external={true}
>
<FormattedMessage
defaultMessage="visit the documentation"
id="xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link"
/>
</EuiLink>
),
}}
/>
<EuiSpacer size="s" />
<EuiLink
onClick={() => {
: accuracyModeDisabledWarning(column.name, column.id, () => {
setState((prevState) =>
mergeLayer({
state: prevState,
@ -367,24 +349,81 @@ export function getPrecisionErrorWarningMessages(
updateColumnParam({
layer: currentLayer,
columnId: column.id,
paramName: 'orderBy',
value: {
type: 'rare',
maxDocCount: DEFAULT_MAX_DOC_COUNT,
},
paramName: 'accuracyMode',
value: true,
}),
indexPattern
),
})
);
}}
>
{i18n.translate('xpack.lens.indexPattern.switchToRare', {
defaultMessage: 'Rank by rarity',
})}
</EuiLink>
</>
})
);
} else {
warningMessages.push({
severity: 'warning',
displayLocations: [
{ id: 'toolbar' },
{ id: 'dimensionButton', dimensionId: column.id },
],
shortMessage: i18n.translate(
'xpack.lens.indexPattern.precisionErrorWarning.ascendingCountPrecisionErrorWarning.shortMessage',
{
defaultMessage:
'This may be approximate depending on how the data is indexed. For more precise results, sort by rarity.',
}
),
longMessage: (
<>
<FormattedMessage
id="xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning"
defaultMessage="{name} for this visualization may be approximate due to how the data is indexed. Try sorting by rarity instead of ascending count of records. To learn more about this limit, {link}."
values={{
name: <strong>{column.name}</strong>,
link: (
<EuiLink
href={docLinks.links.aggs.rare_terms}
target="_blank"
external={true}
>
<FormattedMessage
defaultMessage="visit the documentation"
id="xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link"
/>
</EuiLink>
),
}}
/>
<EuiSpacer size="s" />
<EuiLink
onClick={() => {
setState((prevState) =>
mergeLayer({
state: prevState,
layerId,
newLayer: updateDefaultLabels(
updateColumnParam({
layer: currentLayer,
columnId: column.id,
paramName: 'orderBy',
value: {
type: 'rare',
maxDocCount: DEFAULT_MAX_DOC_COUNT,
},
}),
indexPattern
),
})
);
}}
>
{i18n.translate('xpack.lens.indexPattern.switchToRare', {
defaultMessage: 'Rank by rarity',
})}
</EuiLink>
</>
),
fixableInEditor: true,
});
}
}
});

View file

@ -233,13 +233,6 @@ export function getTextBasedDatasource({
});
return { state: { layers }, savedObjectReferences };
},
isValidColumn(state, indexPatterns, layerId, columnId) {
const layer = state.layers[layerId];
const column = layer.columns.find((c) => c.columnId === columnId);
const indexPattern = indexPatterns[layer.index];
if (!column || !indexPattern) return false;
return true;
},
insertLayer(state: TextBasedPrivateState, newLayerId: string) {
const layer = Object.values(state?.layers)?.[0];
const query = layer?.query;

View file

@ -6,11 +6,13 @@
*/
import React from 'react';
import { EuiButtonIcon, EuiLink } from '@elastic/eui';
import { EuiButtonIcon, EuiFlexItem, EuiLink, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ColorIndicator } from '../color_indicator';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { DimensionButtonIcon } from '../dimension_button_icon';
import { PaletteIndicator } from '../palette_indicator';
import { VisualizationDimensionGroupConfig, AccessorConfig } from '../../../../types';
import { VisualizationDimensionGroupConfig, AccessorConfig, UserMessage } from '../../../../types';
const triggerLinkA11yText = (label: string) =>
i18n.translate('xpack.lens.configure.editConfig', {
@ -25,7 +27,7 @@ export function DimensionButton({
onRemoveClick,
accessorConfig,
label,
invalid,
message,
}: {
group: VisualizationDimensionGroupConfig;
children: React.ReactElement;
@ -33,25 +35,39 @@ export function DimensionButton({
onRemoveClick: (id: string) => void;
accessorConfig: AccessorConfig;
label: string;
invalid?: boolean;
message: UserMessage | undefined;
}) {
return (
<>
<EuiLink
className="lnsLayerPanel__dimensionLink"
data-test-subj="lnsLayerPanel-dimensionLink"
onClick={() => onClick(accessorConfig.columnId)}
aria-label={triggerLinkA11yText(label)}
title={triggerLinkA11yText(label)}
color={invalid ? 'danger' : undefined}
>
<ColorIndicator accessorConfig={accessorConfig}>{children}</ColorIndicator>
</EuiLink>
<EuiFlexItem>
<EuiToolTip
content={message?.shortMessage || message?.longMessage || undefined}
position="left"
>
<EuiLink
className="lnsLayerPanel__dimensionLink"
data-test-subj="lnsLayerPanel-dimensionLink"
onClick={() => onClick(accessorConfig.columnId)}
aria-label={triggerLinkA11yText(label)}
title={triggerLinkA11yText(label)}
color={
message?.severity === 'error'
? 'danger'
: message?.severity === 'warning'
? 'warning'
: undefined
}
>
<DimensionButtonIcon message={message} accessorConfig={accessorConfig}>
{children}
</DimensionButtonIcon>
</EuiLink>
</EuiToolTip>
</EuiFlexItem>
<EuiButtonIcon
className="lnsLayerPanel__dimensionRemove"
data-test-subj="indexPattern-dimension-remove"
iconType="cross"
iconSize="s"
iconType="trash"
size="s"
color="danger"
aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
@ -63,6 +79,12 @@ export function DimensionButton({
values: { groupLabel: group.groupLabel },
})}
onClick={() => onRemoveClick(accessorConfig.columnId)}
css={css`
color: ${euiThemeVars.euiTextSubduedColor};
&:hover {
color: ${euiThemeVars.euiColorDangerText};
}
`}
/>
<PaletteIndicator accessorConfig={accessorConfig} />
</>

View file

@ -1,104 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AccessorConfig } from '../../../types';
export function ColorIndicator({
accessorConfig,
children,
}: {
accessorConfig: AccessorConfig;
children: React.ReactChild;
}) {
let indicatorIcon = null;
if (accessorConfig.triggerIconType && accessorConfig.triggerIconType !== 'none') {
const baseIconProps = {
size: 's',
className: 'lnsLayerPanel__colorIndicator',
} as const;
indicatorIcon = (
<EuiFlexItem grow={false}>
{accessorConfig.triggerIconType === 'color' && accessorConfig.color && (
<EuiIcon
{...baseIconProps}
color={accessorConfig.color}
type="stopFilled"
aria-label={i18n.translate('xpack.lens.editorFrame.colorIndicatorLabel', {
defaultMessage: 'Color of this dimension: {hex}',
values: {
hex: accessorConfig.color,
},
})}
/>
)}
{accessorConfig.triggerIconType === 'disabled' && (
<EuiIcon
{...baseIconProps}
type="stopSlash"
color="subdued"
aria-label={i18n.translate('xpack.lens.editorFrame.noColorIndicatorLabel', {
defaultMessage: 'This dimension does not have an individual color',
})}
/>
)}
{accessorConfig.triggerIconType === 'invisible' && (
<EuiIcon
{...baseIconProps}
type="eyeClosed"
color="subdued"
aria-label={i18n.translate('xpack.lens.editorFrame.invisibleIndicatorLabel', {
defaultMessage: 'This dimension is currently not visible in the chart',
})}
/>
)}
{accessorConfig.triggerIconType === 'aggregate' && (
<EuiIcon
{...baseIconProps}
type="fold"
color="subdued"
aria-label={i18n.translate('xpack.lens.editorFrame.aggregateIndicatorLabel', {
defaultMessage:
'This dimension is not visible in the chart because all individual values are aggregated into a single value',
})}
/>
)}
{accessorConfig.triggerIconType === 'colorBy' && (
<EuiIcon
{...baseIconProps}
type="brush"
color="text"
aria-label={i18n.translate('xpack.lens.editorFrame.paletteColorIndicatorLabel', {
defaultMessage: 'This dimension is using a palette',
})}
/>
)}
{accessorConfig.triggerIconType === 'custom' && accessorConfig.customIcon && (
<EuiIcon
{...baseIconProps}
size="m"
type={accessorConfig.customIcon}
color={accessorConfig.color}
aria-label={i18n.translate('xpack.lens.editorFrame.customIconIndicatorLabel', {
defaultMessage: 'This dimension is using a custom icon',
})}
/>
)}
</EuiFlexItem>
);
}
return (
<EuiFlexGroup gutterSize="none" alignItems="center" responsive={false}>
{indicatorIcon}
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AccessorConfig, UserMessage } from '../../../types';
import { IconError, IconWarning } from '../custom_icons';
const baseIconProps = {
className: 'lnsLayerPanel__colorIndicator',
} as const;
const getIconFromAccessorConfig = (accessorConfig: AccessorConfig) => (
<>
{accessorConfig.triggerIconType === 'color' && accessorConfig.color && (
<EuiIcon
{...baseIconProps}
color={accessorConfig.color}
type="stopFilled"
aria-label={i18n.translate('xpack.lens.editorFrame.colorIndicatorLabel', {
defaultMessage: 'Color of this dimension: {hex}',
values: {
hex: accessorConfig.color,
},
})}
/>
)}
{accessorConfig.triggerIconType === 'disabled' && (
<EuiIcon
{...baseIconProps}
type="stopSlash"
color="subdued"
aria-label={i18n.translate('xpack.lens.editorFrame.noColorIndicatorLabel', {
defaultMessage: 'This dimension does not have an individual color',
})}
/>
)}
{accessorConfig.triggerIconType === 'invisible' && (
<EuiIcon
{...baseIconProps}
type="eyeClosed"
color="subdued"
aria-label={i18n.translate('xpack.lens.editorFrame.invisibleIndicatorLabel', {
defaultMessage: 'This dimension is currently not visible in the chart',
})}
/>
)}
{accessorConfig.triggerIconType === 'aggregate' && (
<EuiIcon
{...baseIconProps}
type="fold"
color="subdued"
aria-label={i18n.translate('xpack.lens.editorFrame.aggregateIndicatorLabel', {
defaultMessage:
'This dimension is not visible in the chart because all individual values are aggregated into a single value',
})}
/>
)}
{accessorConfig.triggerIconType === 'colorBy' && (
<EuiIcon
{...baseIconProps}
type="color"
color="text"
aria-label={i18n.translate('xpack.lens.editorFrame.paletteColorIndicatorLabel', {
defaultMessage: 'This dimension is using a palette',
})}
/>
)}
{accessorConfig.triggerIconType === 'custom' && accessorConfig.customIcon && (
<EuiIcon
{...baseIconProps}
type={accessorConfig.customIcon}
color={accessorConfig.color}
aria-label={i18n.translate('xpack.lens.editorFrame.customIconIndicatorLabel', {
defaultMessage: 'This dimension is using a custom icon',
})}
/>
)}
</>
);
export function DimensionButtonIcon({
accessorConfig,
message,
children,
}: {
accessorConfig: AccessorConfig;
message: UserMessage | undefined;
children: React.ReactChild;
}) {
let indicatorIcon = null;
if (message || accessorConfig.triggerIconType !== 'none') {
indicatorIcon = (
<>
{accessorConfig.triggerIconType !== 'none' && (
<EuiFlexItem grow={false}>{getIconFromAccessorConfig(accessorConfig)}</EuiFlexItem>
)}
{message && (
<EuiFlexItem grow={false}>
<EuiIcon
{...baseIconProps}
type={message.severity === 'error' ? IconError : IconWarning}
/>
</EuiFlexItem>
)}
</>
);
}
return (
<EuiFlexGroup gutterSize="none" alignItems="center" responsive={false}>
{indicatorIcon}
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -512,17 +512,10 @@ export function LayerPanel(
{group.accessors.map((accessorConfig, accessorIndex) => {
const { columnId } = accessorConfig;
const messages = props.getUserMessages('dimensionTrigger', {
// TODO - support warnings
severity: 'error',
const messages = props.getUserMessages('dimensionButton', {
dimensionId: columnId,
});
const hasMessages = Boolean(messages.length);
const messageToDisplay = hasMessages
? messages[0].shortMessage || messages[0].longMessage
: undefined;
return (
<DraggableDimensionButton
activeVisualization={activeVisualization}
@ -574,16 +567,7 @@ export function LayerPanel(
props.onRemoveDimension({ columnId: id, layerId });
removeButtonRef(id);
}}
invalid={
layerDatasource &&
!layerDatasource?.isValidColumn(
layerDatasourceState,
dataViews.indexPatterns,
layerId,
columnId,
dateRange
)
}
message={messages[0]}
>
{layerDatasource ? (
<NativeRenderer
@ -593,9 +577,6 @@ export function LayerPanel(
columnId: accessorConfig.columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
hideTooltip,
invalid: hasMessages,
invalidMessage: messageToDisplay,
indexPatterns: dataViews.indexPatterns,
}}
/>
@ -605,8 +586,6 @@ export function LayerPanel(
columnId,
label: columnLabelMap?.[columnId] ?? '',
hideTooltip,
invalid: hasMessages,
invalidMessage: messageToDisplay,
})}
</>
)}

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// NOTICE — all this can be removed when https://github.com/elastic/eui/pull/6550 gets pulled into Kibana
import React from 'react';
import { EuiIconProps } from '@elastic/eui';
export const IconError = ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
aria-labelledby={titleId}
fill="none"
{...props}
>
{title ? <title id={titleId}>{title}</title> : null}
<path
fillRule="evenodd"
d="M10 1a1 1 0 0 1 .707.293l4 4A1 1 0 0 1 15 6v5a1 1 0 0 1-.293.707l-4 4A1 1 0 0 1 10 16H5a1 1 0 0 1-.707-.293l-4-4A1 1 0 0 1 0 11V6a1 1 0 0 1 .293-.707l4-4A1 1 0 0 1 5 1h5ZM4.146 5.146a.5.5 0 0 1 .708 0L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708.708L8.207 8.5l2.647 2.646a.5.5 0 0 1-.708.708L7.5 9.207l-2.646 2.647a.5.5 0 0 1-.708-.708L6.793 8.5 4.146 5.854a.5.5 0 0 1 0-.708Z"
/>
</svg>
);
export const IconWarning = ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
aria-labelledby={titleId}
fill="none"
{...props}
>
{title ? <title id={titleId}>{title}</title> : null}
<path d="m8.55 9.502.35-3.507a.905.905 0 1 0-1.8 0l.35 3.507a.552.552 0 0 0 1.1 0ZM9 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" />
<path d="M8.864 1.496a1 1 0 0 0-1.728 0l-7 12A1 1 0 0 0 1 15h14a1 1 0 0 0 .864-1.504l-7-12ZM1 14 8 2l7 12H1Z" />
</svg>
);

View file

@ -63,7 +63,7 @@ import {
selectStagedActiveData,
selectFrameDatasourceAPI,
} from '../../state_management';
import { filterUserMessages } from '../../app_plugin/get_application_user_messages';
import { filterAndSortUserMessages } from '../../app_plugin/get_application_user_messages';
const MAX_SUGGESTIONS_DISPLAYED = 5;
const LOCAL_STORAGE_SUGGESTIONS_PANEL = 'LENS_SUGGESTIONS_PANEL_HIDDEN';
@ -77,7 +77,7 @@ const configurationsValid = (
): boolean => {
try {
return (
filterUserMessages(
filterAndSortUserMessages(
[
...(currentDataSource?.getUserMessages?.(currentDatasourceState, {
frame,

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import './workspace_panel_wrapper.scss';
import './message_list.scss';
import React, { useState } from 'react';
import {
EuiPopover,
EuiText,
EuiButton,
EuiIcon,
EuiToolTip,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css, SerializedStyles } from '@emotion/react';
import { IconError, IconWarning } from '../custom_icons';
import { UserMessage } from '../../../types';
export const MessageList = ({
messages,
customButtonStyles,
}: {
messages: UserMessage[];
customButtonStyles?: SerializedStyles;
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
let warningCount = 0;
let errorCount = 0;
messages.forEach(({ severity }) => {
if (severity === 'warning') {
warningCount++;
} else {
errorCount++;
}
});
const buttonLabel =
errorCount > 0 && warningCount > 0
? i18n.translate('xpack.lens.messagesButton.label.errorsAndWarnings', {
defaultMessage:
'{errorCount} {errorCount, plural, one {error} other {errors}}, {warningCount} {warningCount, plural, one {warning} other {warnings}}',
values: {
errorCount,
warningCount,
},
})
: errorCount > 0
? i18n.translate('xpack.lens.messagesButton.label.errors', {
defaultMessage: '{errorCount} {errorCount, plural, one {error} other {errors}}',
values: {
errorCount,
},
})
: i18n.translate('xpack.lens.messagesButton.label.warnings', {
defaultMessage: '{warningCount} {warningCount, plural, one {warning} other {warnings}}',
values: {
warningCount,
},
});
const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen);
const closePopover = () => setIsPopoverOpen(false);
return (
<EuiPopover
panelPaddingSize="none"
button={
<EuiToolTip content={buttonLabel}>
<EuiButton
minWidth={0}
color={errorCount ? 'danger' : 'warning'}
onClick={onButtonClick}
className="lnsWorkspaceWarning__button"
data-test-subj="lens-message-list-trigger"
title={buttonLabel}
css={customButtonStyles}
>
{errorCount > 0 && (
<>
<EuiIcon type={IconError} />
{errorCount}
</>
)}
{warningCount > 0 && (
<>
<EuiIcon
type={IconWarning}
css={css`
margin-left: 4px;
`}
/>
{warningCount}
</>
)}
</EuiButton>
</EuiToolTip>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
>
<ul className="lnsWorkspaceWarningList">
{messages.map((message, index) => (
<li
key={index}
className="lnsWorkspaceWarningList__item"
data-test-subj={`lens-message-list-${message.severity}`}
>
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
{message.severity === 'error' ? (
<EuiIcon type={IconError} color="danger" />
) : (
<EuiIcon type={IconWarning} color="warning" />
)}
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiText size="s">{message.longMessage}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</li>
))}
</ul>
</EuiPopover>
);
};

View file

@ -1,68 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import './workspace_panel_wrapper.scss';
import './warnings_popover.scss';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiText, EuiButtonEmpty } from '@elastic/eui';
export const WarningsPopover = ({
children,
}: {
children?: React.ReactNode | React.ReactNode[];
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
if (!children) {
return null;
}
const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen);
const closePopover = () => setIsPopoverOpen(false);
const warningsCount = React.Children.count(children);
return (
<EuiPopover
panelPaddingSize="none"
button={
<EuiButtonEmpty
color="warning"
onClick={onButtonClick}
iconType="alert"
className="lnsWorkspaceWarning__button"
data-test-subj="lens-editor-warning-button"
>
{warningsCount}
<span className="lnsWorkspaceWarning__buttonText">
{' '}
{i18n.translate('xpack.lens.chartWarnings.number', {
defaultMessage: `{warningsCount, plural, one {warning} other {warnings}}`,
values: {
warningsCount,
},
})}
</span>
</EuiButtonEmpty>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
>
<ul className="lnsWorkspaceWarningList">
{React.Children.map(children, (child, index) => (
<li
key={index}
className="lnsWorkspaceWarningList__item"
data-test-subj="lens-editor-warning"
>
<EuiText size="s">{child}</EuiText>
</li>
))}
</ul>
</EuiPopover>
);
};

View file

@ -20,7 +20,7 @@ import {
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../../../utils';
import { NativeRenderer } from '../../../native_renderer';
import { ChartSwitch } from './chart_switch';
import { WarningsPopover } from './warnings_popover';
import { MessageList } from './message_list';
import {
useLensDispatch,
updateVisualizationState,
@ -30,7 +30,6 @@ import {
selectChangesApplied,
applyChanges,
selectAutoApplyEnabled,
selectStagedRequestWarnings,
} from '../../../state_management';
import { WorkspaceTitle } from './title';
import { LensInspector } from '../../../lens_inspector_service';
@ -64,7 +63,6 @@ export function WorkspacePanelWrapper({
const changesApplied = useLensSelector(selectChangesApplied);
const autoApplyEnabled = useLensSelector(selectAutoApplyEnabled);
const requestWarnings = useLensSelector(selectStagedRequestWarnings);
const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null;
const setVisualizationState = useCallback(
@ -82,15 +80,8 @@ export function WorkspacePanelWrapper({
[dispatchLens, activeVisualization]
);
const warningMessages: React.ReactNode[] = [];
const userMessages = getUserMessages('toolbar');
warningMessages.push(
...getUserMessages('toolbar', { severity: 'warning' }).map(({ longMessage }) => longMessage)
);
if (requestWarnings) {
warningMessages.push(...requestWarnings);
}
return (
<EuiPageTemplate
direction="column"
@ -99,7 +90,7 @@ export function WorkspacePanelWrapper({
restrictWidth={false}
mainProps={{ component: 'div' } as unknown as {}}
>
{!(isFullscreen && (autoApplyEnabled || warningMessages?.length)) && (
{!(isFullscreen && (autoApplyEnabled || userMessages?.length)) && (
<EuiPageTemplate.Section paddingSize="none" color="transparent">
<EuiFlexGroup
alignItems="flexEnd"
@ -140,9 +131,9 @@ export function WorkspacePanelWrapper({
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
{warningMessages?.length ? (
{userMessages?.length ? (
<EuiFlexItem grow={false}>
<WarningsPopover>{warningMessages}</WarningsPopover>
<MessageList messages={userMessages} />
</EuiFlexItem>
) : null}

View file

@ -70,10 +70,10 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import {
BrushTriggerEvent,
ClickTriggerEvent,
Warnings,
MultiClickTriggerEvent,
} from '@kbn/charts-plugin/public';
import { DataViewSpec } from '@kbn/data-views-plugin/common';
import { useEuiFontSize, useEuiTheme } from '@elastic/eui';
import { getExecutionContextEvents, trackUiCounterEvents } from '../lens_ui_telemetry';
import { Document } from '../persistence';
import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper';
@ -112,9 +112,10 @@ import {
} from '../utils';
import { getLayerMetaInfo, combineQueryAndFilters } from '../app_plugin/show_underlying_data';
import {
filterUserMessages,
filterAndSortUserMessages,
getApplicationUserMessages,
} from '../app_plugin/get_application_user_messages';
import { MessageList } from '../editor_frame_service/editor_frame/workspace_panel/message_list';
export type LensSavedObjectAttributes = Omit<Document, 'savedObjectId' | 'type'>;
@ -274,6 +275,26 @@ function getViewUnderlyingDataArgs({
};
}
const EmbeddableMessagesPopover = ({ messages }: { messages: UserMessage[] }) => {
const { euiTheme } = useEuiTheme();
const xsFontSize = useEuiFontSize('xs').fontSize;
return (
<MessageList
messages={messages}
customButtonStyles={css`
block-size: ${euiTheme.size.l};
border-radius: 0 ${euiTheme.border.radius.medium} 0 ${euiTheme.border.radius.small};
font-size: ${xsFontSize};
padding: 0 ${euiTheme.size.xs};
& > * {
gap: ${euiTheme.size.xs};
}
`}
/>
);
};
export class Embeddable
extends AbstractEmbeddable<LensEmbeddableInput, LensEmbeddableOutput>
implements
@ -289,7 +310,7 @@ export class Embeddable
private savedVis: Document | undefined;
private expression: string | undefined | null;
private domNode: HTMLElement | Element | undefined;
private warningDomNode: HTMLElement | Element | undefined;
private badgeDomNode: HTMLElement | Element | undefined;
private subscription: Subscription;
private isInitialized = false;
private inputReloadSubscriptions: Subscription[];
@ -474,10 +495,10 @@ export class Embeddable
}
public getUserMessages: UserMessagesGetter = (locationId, filters) => {
return filterUserMessages(
return filterAndSortUserMessages(
[...this._userMessages, ...Object.values(this.additionalUserMessages)],
locationId,
filters
filters ?? {}
);
};
@ -878,28 +899,25 @@ export class Embeddable
})}
ref={(el) => {
if (el) {
this.warningDomNode = el;
this.badgeDomNode = el;
this.renderBadgeMessages();
}
}}
/>
</KibanaThemeProvider>,
domNode
);
this.renderBadgeMessages();
}
private renderBadgeMessages() {
const warningsToDisplay = this.getUserMessages('embeddableBadge', {
severity: 'warning',
});
const messages = this.getUserMessages('embeddableBadge');
if (warningsToDisplay.length && this.warningDomNode) {
if (messages.length && this.badgeDomNode) {
render(
<KibanaThemeProvider theme$={this.deps.theme.theme$}>
<Warnings warnings={warningsToDisplay.map((message) => message.longMessage)} compressed />
<EmbeddableMessagesPopover messages={messages} />
</KibanaThemeProvider>,
this.warningDomNode
this.badgeDomNode
);
}
}

View file

@ -66,7 +66,6 @@ export function createMockDatasource(
getUserMessages: jest.fn((_state, _deps) => []),
checkIntegrity: jest.fn((_state, _indexPatterns) => []),
isTimeBased: jest.fn(),
isValidColumn: jest.fn(),
isEqual: jest.fn(),
getUsedDataView: jest.fn((state, layer) => 'mockip'),
getUsedDataViews: jest.fn(),

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiText, EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiText, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
@ -20,40 +20,7 @@ export const defaultDimensionTriggerTooltip = (
</p>
);
export const DimensionTrigger = ({
id,
label,
isInvalid,
hideTooltip,
invalidMessage = defaultDimensionTriggerTooltip,
}: {
label: string;
id: string;
isInvalid?: boolean;
hideTooltip?: boolean;
invalidMessage?: string | React.ReactNode;
}) => {
if (isInvalid) {
return (
<EuiToolTip content={!hideTooltip ? invalidMessage : null} anchorClassName="eui-displayBlock">
<EuiText
size="s"
color="danger"
id={id}
className="lnsLayerPanel__triggerText"
data-test-subj="lns-dimensionTrigger"
>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon size="s" type="alert" />
</EuiFlexItem>
<EuiFlexItem grow={true}>{label}</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
</EuiToolTip>
);
}
export const DimensionTrigger = ({ id, label }: { label: string; id: string }) => {
return (
<EuiText
size="s"

View file

@ -28,8 +28,6 @@ export const selectVisualization = (state: LensState) => state.lens.visualizatio
export const selectStagedPreview = (state: LensState) => state.lens.stagedPreview;
export const selectStagedActiveData = (state: LensState) =>
state.lens.stagedPreview?.activeData || state.lens.activeData;
export const selectStagedRequestWarnings = (state: LensState) =>
state.lens.stagedPreview?.requestWarnings || state.lens.requestWarnings;
export const selectAutoApplyEnabled = (state: LensState) => !state.lens.autoApplyDisabled;
export const selectChangesApplied = (state: LensState) =>
!state.lens.autoApplyDisabled || Boolean(state.lens.changesApplied);

View file

@ -38,7 +38,6 @@ export interface PreviewState {
visualization: VisualizationState;
datasourceStates: DatasourceStates;
activeData?: TableInspectorAdapter;
requestWarnings?: string[];
}
export interface EditorFrameState extends PreviewState {
activeDatasourceId: string | null;

View file

@ -289,7 +289,7 @@ type UserMessageDisplayLocation =
| 'textBasedLanguagesQueryInput'
| 'banner';
}
| { id: 'dimensionTrigger'; dimensionId: string };
| { id: 'dimensionButton'; dimensionId: string };
export type UserMessagesDisplayLocationId = UserMessageDisplayLocation['id'];
@ -311,7 +311,7 @@ export interface UserMessageFilters {
export type UserMessagesGetter = (
locationId: UserMessagesDisplayLocationId | UserMessagesDisplayLocationId[] | undefined,
filters: UserMessageFilters
filters?: UserMessageFilters
) => UserMessage[];
export type AddUserMessages = (messages: RemovableUserMessage[]) => () => void;
@ -505,16 +505,6 @@ export interface Datasource<T = unknown, P = unknown> {
* Checks if the visualization created is time based, for example date histogram
*/
isTimeBased: (state: T, indexPatterns: IndexPatternMap) => boolean;
/**
* Given the current state layer and a columnId will verify if the column configuration has errors
*/
isValidColumn: (
state: T,
indexPatterns: IndexPatternMap,
layerId: string,
columnId: string,
dateRange?: DateRange
) => boolean;
/**
* Are these datasources equivalent?
*/
@ -656,9 +646,6 @@ export type DatasourceDimensionProps<T> = SharedDimensionProps & {
activeData?: Record<string, Datatable>;
dateRange: DateRange;
indexPatterns: IndexPatternMap;
hideTooltip?: boolean;
invalid?: boolean;
invalidMessage?: string | React.ReactNode;
};
export type ParamEditorCustomProps = Record<string, unknown> & {
labels?: string[];
@ -1245,8 +1232,6 @@ export interface Visualization<T = unknown, P = unknown> {
columnId: string;
label: string;
hideTooltip?: boolean;
invalid?: boolean;
invalidMessage?: string | React.ReactNode;
}) => JSX.Element | null;
/**
* Creates map of columns ids and unique lables. Used only for noDatasource layers

View file

@ -554,11 +554,11 @@ describe('gauge', () => {
"displayLocations": Array [
Object {
"dimensionId": "min-accessor",
"id": "dimensionTrigger",
"id": "dimensionButton",
},
Object {
"dimensionId": "max-accessor",
"id": "dimensionTrigger",
"id": "dimensionButton",
},
],
"fixableInEditor": true,

View file

@ -98,8 +98,8 @@ const getErrorMessages = (row?: DatatableRow, state?: GaugeVisualizationState):
errors.push({
severity: 'error',
displayLocations: [
{ id: 'dimensionTrigger', dimensionId: minAccessor! },
{ id: 'dimensionTrigger', dimensionId: maxAccessor! },
{ id: 'dimensionButton', dimensionId: minAccessor! },
{ id: 'dimensionButton', dimensionId: maxAccessor! },
],
fixableInEditor: true,
shortMessage: i18n.translate(
@ -115,8 +115,8 @@ const getErrorMessages = (row?: DatatableRow, state?: GaugeVisualizationState):
errors.push({
severity: 'error',
displayLocations: [
{ id: 'dimensionTrigger', dimensionId: minAccessor! },
{ id: 'dimensionTrigger', dimensionId: maxAccessor! },
{ id: 'dimensionButton', dimensionId: minAccessor! },
{ id: 'dimensionButton', dimensionId: maxAccessor! },
],
fixableInEditor: true,
shortMessage: i18n.translate('xpack.lens.guageVisualization.chartCannotRenderEqual', {

View file

@ -2622,7 +2622,7 @@ describe('xy_visualization', () => {
"displayLocations": Array [
Object {
"dimensionId": "an1",
"id": "dimensionTrigger",
"id": "dimensionButton",
},
],
"fixableInEditor": true,

View file

@ -712,7 +712,7 @@ export const getXyVisualization = ({
errors.push({
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'dimensionTrigger', dimensionId: annotation.id }],
displayLocations: [{ id: 'dimensionButton', dimensionId: annotation.id }],
shortMessage: i18n.translate(
'xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp',
{
@ -732,7 +732,7 @@ export const getXyVisualization = ({
fixableInEditor: true,
displayLocations: [
{ id: 'visualization' },
{ id: 'dimensionTrigger', dimensionId: annotation.id },
{ id: 'dimensionButton', dimensionId: annotation.id },
],
shortMessage: errorMessage,
longMessage: (
@ -889,17 +889,9 @@ export const getXyVisualization = ({
state?.layers.filter(isAnnotationsLayer).map(({ indexPatternId }) => indexPatternId) ?? []
);
},
renderDimensionTrigger({ columnId, label, hideTooltip, invalid, invalidMessage }) {
renderDimensionTrigger({ columnId, label }) {
if (label) {
return (
<DimensionTrigger
id={columnId}
hideTooltip={hideTooltip}
isInvalid={invalid}
invalidMessage={invalidMessage}
label={label || defaultAnnotationLabel}
/>
);
return <DimensionTrigger id={columnId} label={label || defaultAnnotationLabel} />;
}
return null;
},

View file

@ -1206,7 +1206,6 @@
"data.search.aggs.metrics.uniqueCountLabel": "Décompte unique de {field}",
"data.search.aggs.metrics.valueCountLabel": "Décompte de la valeur de {field}",
"data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage": "Le champ enregistré \"{fieldParameter}\" de la vue de données \"{indexPatternTitle}\" n'est pas valide pour une utilisation avec l'agrégation \"{aggType}\". Veuillez sélectionner un nouveau champ.",
"data.search.aggs.paramTypes.field.notFoundSavedFieldParameterErrorMessage": "Le champ \"{fieldParameter}\" associé à cet objet n'existe plus dans la vue de données. Veuillez utiliser un autre champ.",
"data.search.aggs.paramTypes.field.requiredFieldParameterErrorMessage": "{fieldParameter} est un paramètre requis.",
"data.search.aggs.percentageOfLabel": "Pourcentage de {label}",
"data.search.aggs.rareTerms.aggTypesLabel": "Termes rares de {fieldName}",
@ -17939,7 +17938,6 @@
"xpack.lens.app.lensContext": "Contexte Lens ({language})",
"xpack.lens.app.updatePanel": "Mettre à jour le panneau sur {originatingAppName}",
"xpack.lens.chartSwitch.noResults": "Résultats introuvables pour {term}.",
"xpack.lens.chartWarnings.number": "{warningsCount, plural, one {avertissement} other {avertissements}}",
"xpack.lens.configure.configurePanelTitle": "{groupLabel}",
"xpack.lens.configure.editConfig": "Modifier la configuration {label}",
"xpack.lens.configure.suggestedValuee": "Valeur suggérée : {value}",
@ -18040,7 +18038,7 @@
"xpack.lens.indexPattern.formulaWithTooManyArguments": "L'opération {operation} a trop d'arguments",
"xpack.lens.indexPattern.invalidReferenceConfiguration": "La dimension \"{dimensionLabel}\" n'est pas configurée correctement",
"xpack.lens.indexPattern.lastValue.invalidTypeSortField": "Le champ {invalidField} n'est pas un champ de date et ne peut pas être utilisé pour le tri",
"xpack.lens.indexPattern.lastValue.sortFieldNotFound": "Champ {invalidField} introuvable",
"xpack.lens.indexPattern.lastValue.sortFieldNotFound": "Champ {sortField} introuvable",
"xpack.lens.indexPattern.lastValueOf": "Dernière valeur de {name}",
"xpack.lens.indexPattern.layerErrorWrapper": "Erreur de {position} pour le calque : {wrappedMessage}",
"xpack.lens.indexPattern.maxOf": "Maximum de {name}",
@ -18060,7 +18058,7 @@
"xpack.lens.indexPattern.percentileOf": "{percentile, selectordinal, one {#er} two {#e} few {#e} other {#e}} centile de {name}",
"xpack.lens.indexPattern.percentileRanksOf": "Rang centile ({value}) de {name}",
"xpack.lens.indexPattern.pinnedTopValuesLabel": "Filtres de {field}",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled": "{name} peut être une approximation. Vous pouvez activer le mode de précision pour obtenir des résultats plus fins, mais notez que ce mode augmente la charge sur le cluster Elasticsearch. {learnMoreLink}",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled": "{name} peut être une approximation. Vous pouvez activer le mode de précision pour obtenir des résultats plus fins, mais notez que ce mode augmente la charge sur le cluster Elasticsearch.",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled": "{name} peut être une approximation. Pour obtenir des résultats plus précis, essayez d'augmenter le nombre de {topValues} ou d'utiliser des {filters} à la place. {learnMoreLink}",
"xpack.lens.indexPattern.ranges.granularityPopoverExplanation": "La taille de l'intervalle est une valeur de \"gentillesse\". Lorsque la granularité du curseur change, l'intervalle reste le même lorsque l'intervalle de \"gentillesse\" est le même. La granularité minimale est 1, et la valeur maximale est {setting}. Pour modifier la granularité maximale, accédez aux Paramètres avancés.",
"xpack.lens.indexPattern.rareTermsOf": "Valeurs rares de {name}",

View file

@ -1206,7 +1206,6 @@
"data.search.aggs.metrics.uniqueCountLabel": "{field} のユニークカウント",
"data.search.aggs.metrics.valueCountLabel": "{field}の値カウント",
"data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage": "「{aggType}」アグリゲーションで使用するには、データビュー「{indexPatternTitle}」の保存されたフィールド「{fieldParameter}」が無効です。新しいフィールドを選択してください。",
"data.search.aggs.paramTypes.field.notFoundSavedFieldParameterErrorMessage": "このオブジェクトに関連付けられたフィールド\"{fieldParameter}\"は、データビューに存在しません。別のフィールドを使用してください。",
"data.search.aggs.paramTypes.field.requiredFieldParameterErrorMessage": "{fieldParameter} は必須パラメーターです",
"data.search.aggs.percentageOfLabel": "{label} の割合",
"data.search.aggs.rareTerms.aggTypesLabel": "{fieldName}の希少な用語",
@ -17924,7 +17923,6 @@
"xpack.lens.app.lensContext": "Lensコンテキスト{language}",
"xpack.lens.app.updatePanel": "{originatingAppName}でパネルを更新",
"xpack.lens.chartSwitch.noResults": "{term}の結果が見つかりませんでした。",
"xpack.lens.chartWarnings.number": "{warningsCount, plural, other {警告}}",
"xpack.lens.configure.configurePanelTitle": "{groupLabel}",
"xpack.lens.configure.editConfig": "{label}構成の編集",
"xpack.lens.configure.suggestedValuee": "候補の値:{value}",
@ -18020,7 +18018,7 @@
"xpack.lens.indexPattern.formulaWithTooManyArguments": "演算{operation}の引数が多すぎます",
"xpack.lens.indexPattern.invalidReferenceConfiguration": "ディメンション\"{dimensionLabel}\"の構成が正しくありません",
"xpack.lens.indexPattern.lastValue.invalidTypeSortField": "フィールド {invalidField} は日付フィールドではないため、並べ替えで使用できません",
"xpack.lens.indexPattern.lastValue.sortFieldNotFound": "フィールド {invalidField} が見つかりませんでした",
"xpack.lens.indexPattern.lastValue.sortFieldNotFound": "フィールド {sortField} が見つかりませんでした",
"xpack.lens.indexPattern.lastValueOf": "{name} の最後の値",
"xpack.lens.indexPattern.layerErrorWrapper": "レイヤー{position}エラー:{wrappedMessage}",
"xpack.lens.indexPattern.maxOf": "{name} の最高値",
@ -18040,7 +18038,7 @@
"xpack.lens.indexPattern.percentileOf": "{name}の{percentile, selectordinal, other {#}}パーセンタイル",
"xpack.lens.indexPattern.percentileRanksOf": "{name}のパーセンタイルランク({value}",
"xpack.lens.indexPattern.pinnedTopValuesLabel": "{field}のフィルター",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled": "{name}は近似値の可能性があります。より正確な結果を得るために精度モードを有効にできますが、Elasticsearchクラスターの負荷が大きくなります。{learnMoreLink}",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled": "{name}は近似値の可能性があります。より正確な結果を得るために精度モードを有効にできますが、Elasticsearchクラスターの負荷が大きくなります。",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled": "{name}は近似値の可能性があります。より正確な結果を得るには、{topValues}の数を増やすか、{filters}を使用してください。{learnMoreLink}",
"xpack.lens.indexPattern.ranges.granularityPopoverExplanation": "間隔のサイズは「nice」値です。スライダーの粒度を変更すると、「nice」間隔が同じときには、間隔が同じままです。最小粒度は1です。最大値は{setting}です。最大粒度を変更するには、[高度な設定]に移動します。",
"xpack.lens.indexPattern.rareTermsOf": "{name}の希少な値",

View file

@ -1208,7 +1208,6 @@
"data.search.aggs.metrics.uniqueCountLabel": "“{field}”的唯一计数",
"data.search.aggs.metrics.valueCountLabel": "“{field}”的值计数",
"data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage": "数据视图“{indexPatternTitle}”的已保存字段“{fieldParameter}”无效,无法用于“{aggType}”聚合。请选择新字段。",
"data.search.aggs.paramTypes.field.notFoundSavedFieldParameterErrorMessage": "与此对象关联的字段“{fieldParameter}”在该数据视图中已不再存在。请使用其他字段。",
"data.search.aggs.paramTypes.field.requiredFieldParameterErrorMessage": "{fieldParameter} 是必需字段",
"data.search.aggs.percentageOfLabel": "{label} 的百分比",
"data.search.aggs.rareTerms.aggTypesLabel": "{fieldName} 的稀有词",
@ -17944,7 +17943,6 @@
"xpack.lens.app.lensContext": "Lens 上下文 ({language})",
"xpack.lens.app.updatePanel": "更新 {originatingAppName} 中的面板",
"xpack.lens.chartSwitch.noResults": "找不到 {term} 的结果。",
"xpack.lens.chartWarnings.number": "{warningsCount, plural, other {警告}}",
"xpack.lens.configure.configurePanelTitle": "{groupLabel}",
"xpack.lens.configure.editConfig": "编辑 {label} 配置",
"xpack.lens.configure.suggestedValuee": "建议值:{value}",
@ -18045,7 +18043,7 @@
"xpack.lens.indexPattern.formulaWithTooManyArguments": "运算 {operation} 的参数过多",
"xpack.lens.indexPattern.invalidReferenceConfiguration": "维度“{dimensionLabel}”配置不正确",
"xpack.lens.indexPattern.lastValue.invalidTypeSortField": "字段 {invalidField} 不是日期字段,不能用于排序",
"xpack.lens.indexPattern.lastValue.sortFieldNotFound": "未找到字段 {invalidField}",
"xpack.lens.indexPattern.lastValue.sortFieldNotFound": "未找到字段 {sortField}",
"xpack.lens.indexPattern.lastValueOf": "{name} 的最后一个值",
"xpack.lens.indexPattern.layerErrorWrapper": "图层 {position} 错误:{wrappedMessage}",
"xpack.lens.indexPattern.maxOf": "{name} 的最大值",
@ -18065,7 +18063,7 @@
"xpack.lens.indexPattern.percentileOf": "{name} 的{percentile, selectordinal, one {第一个} two {第二个} few {第三个} other {第 #th 个}}百分位数",
"xpack.lens.indexPattern.percentileRanksOf": "{name} 的百分位等级 ({value})",
"xpack.lens.indexPattern.pinnedTopValuesLabel": "{field} 的筛选",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled": "{name} 可能为近似值。可以启用准确性模式以获得更精确的结果,但请注意,这会增加 Elasticsearch 集群的负载。{learnMoreLink}",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled": "{name} 可能为近似值。可以启用准确性模式以获得更精确的结果,但请注意,这会增加 Elasticsearch 集群的负载.",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled": "{name} 可能为近似值。要获得更精确的结果,请尝试增加 {topValues} 的数目或改用 {filters}。{learnMoreLink}",
"xpack.lens.indexPattern.ranges.granularityPopoverExplanation": "时间间隔的大小是“好”值。滑块的粒度更改时,如果“好的”时间间隔不变,时间间隔也不变。最小粒度为 1最大值为 {setting}。要更改最大粒度,请前往“高级设置”。",
"xpack.lens.indexPattern.rareTermsOf": "{name} 的稀有值",

View file

@ -116,16 +116,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await testSubjects.existOrFail('median-partial-warning');
await testSubjects.click('lns-indexPatternDimension-median');
await PageObjects.lens.waitForVisualization('xyVisChart');
await PageObjects.lens.assertEditorWarning(
'Median of kubernetes.container.memory.available.bytes uses a function that is unsupported by rolled up data. Select a different function or change the time range.'
await PageObjects.lens.assertMessageListContains(
'Median of kubernetes.container.memory.available.bytes uses a function that is unsupported by rolled up data. Select a different function or change the time range.',
'warning'
);
});
it('shows warnings in dashboards as well', async () => {
await PageObjects.lens.save('New', false, false, false, 'new');
await PageObjects.dashboard.waitForRenderComplete();
await PageObjects.lens.assertInlineWarning(
'Median of kubernetes.container.memory.available.bytes uses a function that is unsupported by rolled up data. Select a different function or change the time range.'
await PageObjects.lens.assertMessageListContains(
'Median of kubernetes.container.memory.available.bytes uses a function that is unsupported by rolled up data. Select a different function or change the time range.',
'warning'
);
});
});

View file

@ -11,6 +11,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects([
'visualize',
'lens',
'dashboard',
'header',
'timePicker',
'common',
@ -19,30 +20,31 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const security = getService('security');
const listingTable = getService('listingTable');
const kibanaServer = getService('kibanaServer');
const dashboardPanelActions = getService('dashboardPanelActions');
describe('Lens error handling', () => {
before(async () => {
await security.testUser.setRoles(
['global_discover_read', 'global_visualize_read', 'test_logstash_reader'],
{ skipBrowserRefresh: true }
);
// loading an object without reference fails, so we load data view + lens object and then unload data view
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/lens/errors'
);
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/lens/errors2'
);
});
after(async () => {
await security.testUser.restoreDefaults();
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/lens/errors'
);
});
describe('Index Pattern missing', () => {
before(async () => {
await security.testUser.setRoles(
['global_discover_read', 'global_visualize_read', 'test_logstash_reader'],
{ skipBrowserRefresh: true }
);
// loading an object without reference fails, so we load data view + lens object and then unload data view
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/lens/errors'
);
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/lens/errors2'
);
});
after(async () => {
await security.testUser.restoreDefaults();
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/lens/errors'
);
});
it('the warning is shown and user can fix the state', async () => {
await PageObjects.visualize.gotoVisualizationLandingPage();
await listingTable.searchForItemWithName('lnsMetricWithNonExistingDataView');
@ -76,5 +78,31 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.waitForMissingDataViewWarning();
});
});
it('does not block render when missing fields', async () => {
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/lens/missing_fields'
);
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.loadSavedDashboard('Dashboard with missing field Lens');
await PageObjects.lens.assertMessageListContains(
'Field missing field was not found.',
'error'
);
await PageObjects.lens.waitForVisualization('mtrVis');
await PageObjects.dashboard.switchToEditMode();
await dashboardPanelActions.editPanelByTitle();
await PageObjects.lens.assertMessageListContains(
'Field missing field was not found.',
'error'
);
await PageObjects.lens.waitForVisualization('mtrVis');
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/lens/missing_fields'
);
});
});
}

View file

@ -136,7 +136,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
formula: `asdf`,
});
expect(await PageObjects.lens.getWorkspaceErrorCount()).to.eql(1);
await PageObjects.lens.assertMessageListContains('Field asdf was not found.', 'error');
});
it('should keep the formula when entering expanded mode', async () => {

View file

@ -0,0 +1,27 @@
{
"attributes": {
"description": "",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
},
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}",
"panelsJSON": "[{\"version\":\"8.7.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"7d75b51d-19fc-4dba-8174-b82e2905745d\"},\"panelIndex\":\"7d75b51d-19fc-4dba-8174-b82e2905745d\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-78c0d2e4-4bb8-49e7-ae98-cbe5851bb959\"}],\"state\":{\"visualization\":{\"layerId\":\"78c0d2e4-4bb8-49e7-ae98-cbe5851bb959\",\"layerType\":\"data\",\"metricAccessor\":\"8630dc6f-828f-4a7d-8283-821cf6b54e4f\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"78c0d2e4-4bb8-49e7-ae98-cbe5851bb959\":{\"columns\":{\"8630dc6f-828f-4a7d-8283-821cf6b54e4f\":{\"label\":\"Median of missing field\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"missing field\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"8630dc6f-828f-4a7d-8283-821cf6b54e4f\"],\"incompleteColumns\":{},\"sampling\":1}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{}}}]",
"timeRestore": false,
"title": "Dashboard with missing field Lens",
"version": 1
},
"coreMigrationVersion": "8.7.0",
"created_at": "2023-01-28T17:47:28.069Z",
"id": "d4cc9840-9f33-11ed-896f-1111cbb8731e",
"migrationVersion": { "dashboard": "8.7.0" },
"references": [
{
"id": "logstash-*",
"name": "7d75b51d-19fc-4dba-8174-b82e2905745d:indexpattern-datasource-layer-78c0d2e4-4bb8-49e7-ae98-cbe5851bb959",
"type": "index-pattern"
}
],
"type": "dashboard",
"updated_at": "2023-01-28T17:47:28.069Z",
"version": "WzM0OCwxXQ=="
}

View file

@ -1600,39 +1600,25 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await testSubjects.missingOrFail('lens-editor-warning');
},
async assertInlineWarning(warningText: string) {
await testSubjects.click('chart-inline-warning-button');
await testSubjects.existOrFail('chart-inline-warning');
const warnings = await testSubjects.findAll('chart-inline-warning');
/**
* Applicable both on the embeddable and in the editor. In both scenarios, a popover containing user messages (errors, warnings) is shown.
*/
async assertMessageListContains(assertText: string, severity: 'warning' | 'error') {
await testSubjects.click('lens-message-list-trigger');
const messageSelector = `lens-message-list-${severity}`;
await testSubjects.existOrFail(messageSelector);
const messages = await testSubjects.findAll(messageSelector);
let found = false;
for (const warning of warnings) {
const text = await warning.getVisibleText();
for (const message of messages) {
const text = await message.getVisibleText();
log.info(text);
if (text === warningText) {
if (text === assertText) {
found = true;
}
}
await testSubjects.click('chart-inline-warning-button');
await testSubjects.click('lens-message-list-trigger');
if (!found) {
throw new Error(`Warning with text "${warningText}" not found`);
}
},
async assertEditorWarning(warningText: string) {
await testSubjects.click('lens-editor-warning-button');
await testSubjects.existOrFail('lens-editor-warning');
const warnings = await testSubjects.findAll('lens-editor-warning');
let found = false;
for (const warning of warnings) {
const text = await warning.getVisibleText();
log.info(text);
if (text === warningText) {
found = true;
}
}
await testSubjects.click('lens-editor-warning-button');
if (!found) {
throw new Error(`Warning with text "${warningText}" not found`);
throw new Error(`Message with text "${assertText}" not found`);
}
},