[Lens] introduce unified user messages system (#147818)

This commit is contained in:
Andrew Tate 2023-01-23 15:06:31 -06:00 committed by GitHub
parent 2a18937ef7
commit 2ed0123c4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 3368 additions and 2582 deletions

View file

@ -181,9 +181,4 @@ export const getRotatingNumberVisualization = ({
domElement
);
},
getErrorMessages(state) {
// Is it possible to break it?
return undefined;
},
});

View file

@ -0,0 +1,104 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`application-level user messages missing index pattern errors generates error if missing an index pattern 1`] = `
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualizationInEditor",
},
],
"fixableInEditor": true,
"longMessage": <React.Fragment>
<p
className="eui-textBreakWord"
data-test-subj="missing-refs-failure"
>
<FormattedMessage
defaultMessage="Data view not found"
id="xpack.lens.editorFrame.dataViewNotFound"
values={Object {}}
/>
</p>
<p
className="eui-textBreakWord"
style={
Object {
"userSelect": "text",
}
}
>
<FormattedMessage
defaultMessage="The {count, plural, one {data view} other {data views}} ({count, plural, one {id} other {ids}}: {indexpatterns}) cannot be found."
id="xpack.lens.indexPattern.missingDataView"
values={
Object {
"count": 1,
"indexpatterns": "missing_pattern",
}
}
/>
<RedirectAppLinks
coreStart={
Object {
"application": Object {
"capabilities": Object {
"management": Object {
"kibana": Object {
"indexPatterns": true,
},
},
"navLinks": Object {
"management": true,
},
},
"getUrlForApp": [MockFunction] {
"calls": Array [
Array [
"management",
Object {
"path": "/kibana/indexPatterns/create",
},
],
],
"results": Array [
Object {
"type": "return",
"value": "fake/url",
},
],
},
},
}
}
>
<a
data-test-subj="configuration-failure-reconfigure-indexpatterns"
href="fake/url"
style={
Object {
"display": "block",
}
}
>
Recreate it in the data view management page.
</a>
</RedirectAppLinks>
</p>
</React.Fragment>,
"severity": "error",
"shortMessage": "",
},
Object {
"displayLocations": Array [
Object {
"id": "visualizationOnEmbeddable",
},
],
"fixableInEditor": true,
"longMessage": "Could not find the data view: missing_pattern",
"severity": "error",
"shortMessage": "",
},
]
`;

View file

@ -36,6 +36,7 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import moment from 'moment';
import { setState, LensAppState } from '../state_management';
import { coreMock } from '@kbn/core/public/mocks';
jest.mock('../editor_frame_service/editor_frame/expression_helpers');
jest.mock('@kbn/core/public');
jest.mock('../persistence/saved_objects_utils/check_for_duplicate_title', () => ({
@ -85,6 +86,7 @@ describe('Lens App', () => {
visualizationMap,
topNavMenuEntryGenerators: [],
theme$: new Observable(),
coreStart: coreMock.createStart(),
};
}
@ -144,6 +146,8 @@ describe('Lens App', () => {
expect(frame.EditorFrameContainer).toHaveBeenLastCalledWith(
{
indexPatternService: expect.any(Object),
getUserMessages: expect.any(Function),
addUserMessages: expect.any(Function),
lensInspector: {
adapters: {
expression: expect.any(Object),

View file

@ -16,7 +16,7 @@ import type { LensAppLocatorParams } from '../../common/locator/locator';
import { LensAppProps, LensAppServices } from './types';
import { LensTopNavMenu } from './lens_top_nav';
import { LensByReferenceInput } from '../embeddable';
import { EditorFrameInstance } from '../types';
import { AddUserMessages, EditorFrameInstance, UserMessage, UserMessagesGetter } from '../types';
import { Document } from '../persistence/saved_object_store';
import {
@ -28,6 +28,9 @@ import {
DispatchSetState,
selectSavedObjectFormat,
updateIndexPatterns,
updateDatasourceState,
selectActiveDatasourceId,
selectFrameDatasourceAPI,
} from '../state_management';
import { SaveModalContainer, runSaveLensVisualization } from './save_modal_container';
import { LensInspector } from '../lens_inspector_service';
@ -38,6 +41,7 @@ import {
createIndexPatternService,
} from '../data_views_service/service';
import { replaceIndexpattern } from '../state_management/lens_slice';
import { filterUserMessages, getApplicationUserMessages } from './get_application_user_messages';
export type SaveProps = Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
@ -62,6 +66,7 @@ export function App({
topNavMenuEntryGenerators,
initialContext,
theme$,
coreStart,
}: LensAppProps) {
const lensAppServices = useKibana<LensAppServices>().services;
@ -98,8 +103,10 @@ export function App({
sharingSavedObjectProps,
isLinkedToOriginatingApp,
searchSessionId,
datasourceStates,
isLoading,
isSaveable,
visualization,
} = useLensSelector((state) => state.lens);
const selectorDependencies = useMemo(
@ -473,6 +480,98 @@ export function App({
})
: undefined;
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
const frameDatasourceAPI = useLensSelector((state) =>
selectFrameDatasourceAPI(state, datasourceMap)
);
const [userMessages, setUserMessages] = useState<UserMessage[]>([]);
useEffect(() => {
setUserMessages([
...(activeDatasourceId
? datasourceMap[activeDatasourceId].getUserMessages(
datasourceStates[activeDatasourceId].state,
{
frame: frameDatasourceAPI,
setState: (newStateOrUpdater) =>
dispatch(
updateDatasourceState({
updater: newStateOrUpdater,
datasourceId: activeDatasourceId,
})
),
}
)
: []),
...(visualization.activeId && visualization.state
? visualizationMap[visualization.activeId]?.getUserMessages?.(visualization.state, {
frame: frameDatasourceAPI,
}) ?? []
: []),
...getApplicationUserMessages({
visualizationType: persistedDoc?.visualizationType,
visualizationMap,
visualization,
activeDatasource: activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceState: activeDatasourceId ? datasourceStates[activeDatasourceId] : null,
core: coreStart,
dataViews: frameDatasourceAPI.dataViews,
}),
]);
}, [
activeDatasourceId,
coreStart,
datasourceMap,
datasourceStates,
dispatch,
frameDatasourceAPI,
persistedDoc?.visualizationType,
visualization,
visualizationMap,
]);
// these are messages managed from other parts of Lens
const [additionalUserMessages, setAdditionalUserMessages] = useState<Record<string, UserMessage>>(
{}
);
const getUserMessages: UserMessagesGetter = (locationId, filterArgs) =>
filterUserMessages(
[...userMessages, ...Object.values(additionalUserMessages)],
locationId,
filterArgs
);
const addUserMessages: AddUserMessages = (messages) => {
const newMessageMap = {
...additionalUserMessages,
};
const addedMessageIds: string[] = [];
messages.forEach((message) => {
if (!newMessageMap[message.uniqueId]) {
addedMessageIds.push(message.uniqueId);
newMessageMap[message.uniqueId] = message;
}
});
if (addedMessageIds.length) {
setAdditionalUserMessages(newMessageMap);
}
return () => {
const withMessagesRemoved = {
...additionalUserMessages,
};
addedMessageIds.forEach((id) => delete withMessagesRemoved[id]);
setAdditionalUserMessages(withMessagesRemoved);
};
};
return (
<>
<div className="lnsApp" data-test-subj="lnsApp" role="main">
@ -506,6 +605,7 @@ export function App({
theme$={theme$}
indexPatternService={indexPatternService}
onTextBasedSavedAndExit={onTextBasedSavedAndExit}
getUserMessages={getUserMessages}
shortUrlService={shortUrlService}
/>
{getLegacyUrlConflictCallout()}
@ -515,6 +615,8 @@ export function App({
showNoDataPopover={showNoDataPopover}
lensInspector={lensInspector}
indexPatternService={indexPatternService}
getUserMessages={getUserMessages}
addUserMessages={addUserMessages}
/>
)}
</div>
@ -582,18 +684,24 @@ export function App({
const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({
editorFrame,
showNoDataPopover,
getUserMessages,
addUserMessages,
lensInspector,
indexPatternService,
}: {
editorFrame: EditorFrameInstance;
lensInspector: LensInspector;
showNoDataPopover: () => void;
getUserMessages: UserMessagesGetter;
addUserMessages: AddUserMessages;
indexPatternService: IndexPatternServiceAPI;
}) {
const { EditorFrameContainer } = editorFrame;
return (
<EditorFrameContainer
showNoDataPopover={showNoDataPopover}
getUserMessages={getUserMessages}
addUserMessages={addUserMessages}
lensInspector={lensInspector}
indexPatternService={indexPatternService}
/>

View file

@ -0,0 +1,368 @@
/*
* 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 { CoreStart } from '@kbn/core/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { mountWithIntl } from '@kbn/test-jest-helpers';
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';
describe('application-level user messages', () => {
it('should generate error if vis type is not provided', () => {
expect(
getApplicationUserMessages({
visualizationType: undefined,
visualizationMap: {},
visualization: { activeId: '', state: {} },
activeDatasource: {} as Datasource,
activeDatasourceState: null,
dataViews: {} as DataViewsState,
core: {} as CoreStart,
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": true,
"longMessage": "Visualization type not found.",
"severity": "warning",
"shortMessage": "",
},
]
`);
});
it('should generate error if vis type is unknown', () => {
expect(
getApplicationUserMessages({
visualizationType: '123',
visualizationMap: {},
visualization: { activeId: 'id_for_type_that_doesnt_exist', state: {} },
activeDatasource: {} as Datasource,
activeDatasourceState: null,
dataViews: {} as DataViewsState,
core: {} as CoreStart,
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": false,
"longMessage": "The visualization type id_for_type_that_doesnt_exist could not be resolved.",
"severity": "error",
"shortMessage": "Unknown visualization type",
},
]
`);
});
it('should generate error if datasource type is unknown', () => {
expect(
getApplicationUserMessages({
activeDatasource: null,
visualizationType: '123',
visualizationMap: { 'some-id': {} as Visualization },
visualization: { activeId: 'some-id', state: {} },
activeDatasourceState: null,
dataViews: {} as DataViewsState,
core: {} as CoreStart,
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": false,
"longMessage": "Could not find datasource for the visualization",
"severity": "error",
"shortMessage": "Unknown datasource type",
},
]
`);
});
describe('missing index pattern errors', () => {
const defaultPermissions: Record<string, Record<string, boolean | Record<string, boolean>>> = {
navLinks: { management: true },
management: { kibana: { indexPatterns: true } },
};
function createCoreStartWithPermissions(newCapabilities = defaultPermissions) {
const core = {
application: {
getUrlForApp: jest.fn(() => 'fake/url'),
capabilities: {
management: {
kibana: {
indexPatterns: true,
},
},
navLinks: {
management: true,
},
},
},
} as unknown as CoreStart;
(core.application.capabilities as unknown as Record<
string,
Record<string, boolean | Record<string, boolean>>
>) = newCapabilities;
return core;
}
const irrelevantProps = {
dataViews: {} as DataViewsState,
visualizationMap: { foo: {} as Visualization },
visualization: { activeId: 'foo', state: {} },
};
it('generates error if missing an index pattern', () => {
expect(
getApplicationUserMessages({
visualizationType: '123',
activeDatasource: {
checkIntegrity: jest.fn(() => ['missing_pattern']),
} as unknown as Datasource,
activeDatasourceState: { state: {} },
core: createCoreStartWithPermissions(),
...irrelevantProps,
})
).toMatchSnapshot();
});
it('doesnt show a recreate link if user has no access', () => {
expect(
mountWithIntl(
<div>
{
getApplicationUserMessages({
visualizationType: '123',
activeDatasource: {
checkIntegrity: jest.fn(() => ['missing_pattern']),
} as unknown as Datasource,
activeDatasourceState: { state: {} },
// user can go to management, but indexPatterns management is not accessible
core: createCoreStartWithPermissions({
navLinks: { management: true },
management: { kibana: { indexPatterns: false } },
}),
...irrelevantProps,
})[0].longMessage
}
</div>
).exists(RedirectAppLinks)
).toBeFalsy();
expect(
shallow(
<div>
{
getApplicationUserMessages({
visualizationType: '123',
activeDatasource: {
checkIntegrity: jest.fn(() => ['missing_pattern']),
} as unknown as Datasource,
activeDatasourceState: { state: {} },
// user can't go to management at all
core: createCoreStartWithPermissions({
navLinks: { management: false },
management: { kibana: { indexPatterns: true } },
}),
...irrelevantProps,
})[0].longMessage
}
</div>
).exists(RedirectAppLinks)
).toBeFalsy();
});
});
});
describe('filtering user messages', () => {
const dimensionId1 = 'foo';
const dimensionId2 = 'baz';
const userMessages: UserMessage[] = [
{
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'dimensionTrigger', dimensionId: dimensionId1 }],
shortMessage: 'Warning on dimension 1!',
longMessage: '',
},
{
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'dimensionTrigger', dimensionId: dimensionId2 }],
shortMessage: 'Warning on dimension 2!',
longMessage: '',
},
{
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'banner' }],
shortMessage: 'Deprecation notice!',
longMessage: '',
},
{
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: 'Visualization error!',
longMessage: '',
},
{
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualizationInEditor' }],
shortMessage: 'Visualization editor error!',
longMessage: '',
},
{
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'visualizationOnEmbeddable' }],
shortMessage: 'Visualization embeddable warning!',
longMessage: '',
},
];
it('filters by location', () => {
expect(filterUserMessages(userMessages, 'banner', {})).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "banner",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "warning",
"shortMessage": "Deprecation notice!",
},
]
`);
expect(
filterUserMessages(userMessages, 'dimensionTrigger', {
dimensionId: dimensionId1,
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"dimensionId": "foo",
"id": "dimensionTrigger",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "error",
"shortMessage": "Warning on dimension 1!",
},
]
`);
expect(
filterUserMessages(userMessages, 'dimensionTrigger', {
dimensionId: dimensionId2,
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"dimensionId": "baz",
"id": "dimensionTrigger",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "warning",
"shortMessage": "Warning on dimension 2!",
},
]
`);
expect(filterUserMessages(userMessages, ['visualization', 'visualizationInEditor'], {}))
.toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "error",
"shortMessage": "Visualization error!",
},
Object {
"displayLocations": Array [
Object {
"id": "visualizationInEditor",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "error",
"shortMessage": "Visualization editor error!",
},
]
`);
});
it('filters by severity', () => {
const warnings = filterUserMessages(userMessages, undefined, { severity: 'warning' });
const errors = filterUserMessages(userMessages, undefined, { severity: 'error' });
expect(warnings.length + errors.length).toBe(userMessages.length);
expect(warnings.every((message) => message.severity === 'warning'));
expect(errors.every((message) => message.severity === 'error'));
});
it('filters by both', () => {
expect(
filterUserMessages(userMessages, ['visualization', 'visualizationOnEmbeddable'], {
severity: 'warning',
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualizationOnEmbeddable",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "warning",
"shortMessage": "Visualization embeddable warning!",
},
]
`);
});
});

View file

@ -0,0 +1,219 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { FormattedMessage } from '@kbn/i18n-react';
import type { CoreStart } from '@kbn/core/public';
import type { DataViewsState, VisualizationState } from '../state_management';
import type {
Datasource,
UserMessage,
UserMessageFilters,
UserMessagesDisplayLocationId,
VisualizationMap,
} from '../types';
import { getMissingIndexPattern } from '../editor_frame_service/editor_frame/state_helpers';
/**
* Provides a place to register general user messages that don't belong in the datasource or visualization objects
*/
export const getApplicationUserMessages = ({
visualizationType,
visualization,
visualizationMap,
activeDatasource,
activeDatasourceState,
dataViews,
core,
}: {
visualizationType: string | null | undefined;
visualization: VisualizationState;
visualizationMap: VisualizationMap;
activeDatasource: Datasource | null;
activeDatasourceState: { state: unknown } | null;
dataViews: DataViewsState;
core: CoreStart;
}): UserMessage[] => {
const messages: UserMessage[] = [];
if (!visualizationType) {
messages.push(getMissingVisTypeError());
}
if (visualization.activeId && !visualizationMap[visualization.activeId]) {
messages.push(getUnknownVisualizationTypeError(visualization.activeId));
}
if (!activeDatasource) {
messages.push(getUnknownDatasourceTypeError());
}
const missingIndexPatterns = getMissingIndexPattern(
activeDatasource,
activeDatasourceState,
dataViews.indexPatterns
);
if (missingIndexPatterns.length) {
messages.push(...getMissingIndexPatternsErrors(core, missingIndexPatterns));
}
return messages;
};
function getMissingVisTypeError(): UserMessage {
return {
severity: 'warning',
displayLocations: [{ id: 'visualization' }],
fixableInEditor: true,
shortMessage: '',
longMessage: i18n.translate('xpack.lens.editorFrame.expressionMissingVisualizationType', {
defaultMessage: 'Visualization type not found.',
}),
};
}
function getUnknownVisualizationTypeError(visType: string): UserMessage {
return {
severity: 'error',
fixableInEditor: false,
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate('xpack.lens.unknownVisType.shortMessage', {
defaultMessage: `Unknown visualization type`,
}),
longMessage: i18n.translate('xpack.lens.unknownVisType.longMessage', {
defaultMessage: `The visualization type {visType} could not be resolved.`,
values: {
visType,
},
}),
};
}
function getUnknownDatasourceTypeError(): UserMessage {
return {
severity: 'error',
fixableInEditor: false,
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate('xpack.lens.unknownDatasourceType.shortMessage', {
defaultMessage: `Unknown datasource type`,
}),
longMessage: i18n.translate('xpack.lens.editorFrame.expressionMissingDatasource', {
defaultMessage: 'Could not find datasource for the visualization',
}),
};
}
function getMissingIndexPatternsErrors(
core: CoreStart,
missingIndexPatterns: string[]
): UserMessage[] {
// Check for access to both Management app && specific indexPattern section
const { management: isManagementEnabled } = core.application.capabilities.navLinks;
const isIndexPatternManagementEnabled =
core.application.capabilities.management.kibana.indexPatterns;
const canFix = isManagementEnabled && isIndexPatternManagementEnabled;
return [
{
severity: 'error',
fixableInEditor: canFix,
displayLocations: [{ id: 'visualizationInEditor' }],
shortMessage: '',
longMessage: (
<>
<p className="eui-textBreakWord" data-test-subj="missing-refs-failure">
<FormattedMessage
id="xpack.lens.editorFrame.dataViewNotFound"
defaultMessage="Data view not found"
/>
</p>
<p
className="eui-textBreakWord"
style={{
userSelect: 'text',
}}
>
<FormattedMessage
id="xpack.lens.indexPattern.missingDataView"
defaultMessage="The {count, plural, one {data view} other {data views}} ({count, plural, one {id} other {ids}}: {indexpatterns}) cannot be found."
values={{
count: missingIndexPatterns.length,
indexpatterns: missingIndexPatterns.join(', '),
}}
/>
{canFix && (
<RedirectAppLinks coreStart={core}>
<a
href={core.application.getUrlForApp('management', {
path: '/kibana/indexPatterns/create',
})}
style={{ display: 'block' }}
data-test-subj="configuration-failure-reconfigure-indexpatterns"
>
{i18n.translate('xpack.lens.editorFrame.dataViewReconfigure', {
defaultMessage: `Recreate it in the data view management page.`,
})}
</a>
</RedirectAppLinks>
)}
</p>
</>
),
},
{
severity: 'error',
fixableInEditor: canFix,
displayLocations: [{ id: 'visualizationOnEmbeddable' }],
shortMessage: '',
longMessage: i18n.translate('xpack.lens.editorFrame.expressionMissingDataView', {
defaultMessage:
'Could not find the {count, plural, one {data view} other {data views}}: {ids}',
values: { count: missingIndexPatterns.length, ids: missingIndexPatterns.join(', ') },
}),
},
];
}
export const filterUserMessages = (
userMessages: UserMessage[],
locationId: UserMessagesDisplayLocationId | UserMessagesDisplayLocationId[] | undefined,
{ dimensionId, severity }: UserMessageFilters
) => {
const locationIds = Array.isArray(locationId)
? locationId
: typeof locationId === 'string'
? [locationId]
: [];
return 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) {
return false;
}
return true;
});
if (!hasMatch) {
return false;
}
}
if (severity && message.severity !== severity) {
return false;
}
return true;
});
};

View file

@ -281,6 +281,7 @@ export const LensTopNavMenu = ({
indexPatternService,
currentDoc,
onTextBasedSavedAndExit,
getUserMessages,
shortUrlService,
isCurrentStateDirty,
}: LensTopNavMenuProps) => {
@ -1032,21 +1033,9 @@ export const LensTopNavMenu = ({
textBasedLanguages: supportedTextBasedLanguages as DataViewPickerProps['textBasedLanguages'],
};
// text based languages errors should also appear to the unified search bar
const textBasedLanguageModeErrors: Error[] = [];
if (activeDatasourceId && allLoaded) {
if (
datasourceMap[activeDatasourceId] &&
datasourceMap[activeDatasourceId].getUnifiedSearchErrors
) {
const errors = datasourceMap[activeDatasourceId].getUnifiedSearchErrors?.(
datasourceStates[activeDatasourceId].state
);
if (errors) {
textBasedLanguageModeErrors.push(...errors);
}
}
}
const textBasedLanguageModeErrors = getUserMessages('textBasedLanguagesQueryInput', {
severity: 'error',
}).map(({ shortMessage }) => new Error(shortMessage));
return (
<AggregateQueryTopNavMenu

View file

@ -364,6 +364,7 @@ export async function mountApp(
contextOriginatingApp={originatingApp}
topNavMenuEntryGenerators={topNavMenuEntryGenerators}
theme$={core.theme.theme$}
coreStart={coreStart}
/>
</Provider>
);

View file

@ -13,6 +13,7 @@ import type {
ApplicationStart,
AppMountParameters,
ChromeStart,
CoreStart,
CoreTheme,
ExecutionContextStart,
HttpStart,
@ -50,6 +51,7 @@ import type {
VisualizeEditorContext,
LensTopNavMenuEntryGenerator,
VisualizationMap,
UserMessagesGetter,
} from '../types';
import type { LensAttributeService } from '../lens_attribute_service';
import type { LensEmbeddableInput } from '../embeddable/embeddable';
@ -82,6 +84,7 @@ export interface LensAppProps {
contextOriginatingApp?: string;
topNavMenuEntryGenerators: LensTopNavMenuEntryGenerator[];
theme$: Observable<CoreTheme>;
coreStart: CoreStart;
}
export type RunSave = (
@ -121,6 +124,7 @@ export interface LensTopNavMenuProps {
theme$: Observable<CoreTheme>;
indexPatternService: IndexPatternServiceAPI;
onTextBasedSavedAndExit: ({ onSave }: { onSave: () => void }) => Promise<void>;
getUserMessages: UserMessagesGetter;
shortUrlService: (params: LensAppLocatorParams) => Promise<string>;
isCurrentStateDirty: boolean;
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import React, { memo } from 'react';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from '@kbn/core/public';
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
@ -15,12 +15,10 @@ import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { DatasourceDimensionTriggerProps, DatasourceDimensionEditorProps } from '../../../types';
import { GenericIndexPatternColumn } from '../form_based';
import { isColumnInvalid } from '../utils';
import { FormBasedPrivateState } from '../types';
import { DimensionEditor } from './dimension_editor';
import { DateRange } from '../../../../common';
import { getOperationSupportMatrix } from './operation_support';
import { DimensionTrigger } from '../../../shared_components/dimension_trigger';
export type FormBasedDimensionTriggerProps =
DatasourceDimensionTriggerProps<FormBasedPrivateState> & {
@ -42,43 +40,6 @@ export type FormBasedDimensionEditorProps =
dateRange: DateRange;
};
function wrapOnDot(str?: string) {
// u200B is a non-width white-space character, which allows
// the browser to efficiently word-wrap right after the dot
// without us having to draw a lot of extra DOM elements, etc
return str ? str.replace(/\./g, '.\u200B') : '';
}
export const FormBasedDimensionTriggerComponent = function FormBasedDimensionTrigger(
props: FormBasedDimensionTriggerProps
) {
const { columnId, uniqueLabel, invalid, invalidMessage, hideTooltip, layerId, dateRange } = props;
const layer = props.state.layers[layerId];
const currentIndexPattern = props.indexPatterns[layer.indexPatternId];
const currentColumnHasErrors = useMemo(
() => invalid || isColumnInvalid(layer, columnId, currentIndexPattern, dateRange),
[layer, columnId, currentIndexPattern, invalid, dateRange]
);
const selectedColumn: GenericIndexPatternColumn | null = layer.columns[props.columnId] ?? null;
if (!selectedColumn) {
return null;
}
const formattedLabel = wrapOnDot(uniqueLabel);
return (
<DimensionTrigger
id={columnId}
label={!currentColumnHasErrors ? formattedLabel : selectedColumn.label}
isInvalid={Boolean(currentColumnHasErrors)}
hideTooltip={hideTooltip}
invalidMessage={invalidMessage}
/>
);
};
export const FormBasedDimensionEditorComponent = function FormBasedDimensionPanel(
props: FormBasedDimensionEditorProps
) {
@ -102,5 +63,4 @@ export const FormBasedDimensionEditorComponent = function FormBasedDimensionPane
);
};
export const FormBasedDimensionTrigger = memo(FormBasedDimensionTriggerComponent);
export const FormBasedDimensionEditor = memo(FormBasedDimensionEditorComponent);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { ReactElement } from 'react';
import { ReactElement } from 'react';
import { SavedObjectReference } from '@kbn/core/public';
import { isFragment } from 'react-is';
import { coreMock } from '@kbn/core/public/mocks';
@ -21,7 +21,14 @@ import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks';
import { TinymathAST } from '@kbn/tinymath';
import { getFormBasedDatasource, GenericIndexPatternColumn } from './form_based';
import { DatasourcePublicAPI, Datasource, FramePublicAPI, OperationDescriptor } from '../../types';
import {
DatasourcePublicAPI,
Datasource,
FramePublicAPI,
OperationDescriptor,
FrameDatasourceAPI,
UserMessage,
} from '../../types';
import { getFieldByNameFactory } from './pure_helpers';
import {
operationDefinitionMap,
@ -43,6 +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';
jest.mock('./loader');
jest.mock('../../id_generator');
@ -3004,237 +3012,298 @@ describe('IndexPattern Data Source', () => {
});
});
describe('#getErrorMessages', () => {
it('should use the results of getErrorMessages directly when single layer', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
const state: FormBasedPrivateState = {
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
},
currentIndexPatternId: '1',
};
expect(FormBasedDatasource.getErrorMessages(state, indexPatterns)).toEqual([
{ longMessage: 'error 1', shortMessage: '' },
{ longMessage: 'error 2', shortMessage: '' },
]);
expect(getErrorMessages).toHaveBeenCalledTimes(1);
});
it('should prepend each error with its layer number on multi-layer chart', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
const state: FormBasedPrivateState = {
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
second: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
},
currentIndexPatternId: '1',
};
expect(FormBasedDatasource.getErrorMessages(state, indexPatterns)).toEqual([
{ longMessage: 'Layer 1 error: error 1', shortMessage: '' },
{ longMessage: 'Layer 1 error: error 2', shortMessage: '' },
]);
expect(getErrorMessages).toHaveBeenCalledTimes(2);
});
});
describe('#getWarningMessages', () => {
let state: FormBasedPrivateState;
let framePublicAPI: FramePublicAPI;
beforeEach(() => {
const termsColumn: TermsIndexPatternColumn = {
operationType: 'terms',
dataType: 'number',
isBucketed: true,
label: '123211',
sourceField: 'foo',
params: {
size: 10,
orderBy: {
type: 'alphabetical',
},
orderDirection: 'asc',
},
};
state = {
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5', 'col6'],
columns: {
col1: {
operationType: 'date_histogram',
params: {
interval: '12h',
},
label: '',
dataType: 'date',
isBucketed: true,
sourceField: 'timestamp',
} as DateHistogramIndexPatternColumn,
col2: {
operationType: 'count',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
col3: {
operationType: 'count',
timeShift: '1h',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
col4: {
operationType: 'count',
timeShift: '13h',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
col5: {
operationType: 'count',
timeShift: '1w',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
col6: {
operationType: 'count',
timeShift: 'previous',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
termsCol: termsColumn,
describe('#getUserMessages', () => {
describe('error messages', () => {
it('should generate error messages for a single layer', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
const state: FormBasedPrivateState = {
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
},
},
currentIndexPatternId: '1',
};
currentIndexPatternId: '1',
};
expect(
FormBasedDatasource.getUserMessages(state, {
frame: { dataViews: { indexPatterns } } as unknown as FrameDatasourceAPI,
setState: () => {},
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": true,
"longMessage": "error 1",
"severity": "error",
"shortMessage": "",
},
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": true,
"longMessage": "error 2",
"severity": "error",
"shortMessage": "",
},
]
`);
expect(getErrorMessages).toHaveBeenCalledTimes(1);
});
framePublicAPI = {
activeData: {
first: {
type: 'datatable',
rows: [],
columns: [
{
id: 'col1',
name: 'col1',
meta: {
type: 'date',
source: 'esaggs',
sourceParams: {
type: 'date_histogram',
params: {
used_interval: '12h',
it('should prepend each error with its layer number on multi-layer chart', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
const state: FormBasedPrivateState = {
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
second: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
},
currentIndexPatternId: '1',
};
expect(
FormBasedDatasource.getUserMessages(state, {
frame: { dataViews: { indexPatterns } } as unknown as FrameDatasourceAPI,
setState: () => {},
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": true,
"longMessage": <FormattedMessage
defaultMessage="Layer {position} error: {wrappedMessage}"
id="xpack.lens.indexPattern.layerErrorWrapper"
values={
Object {
"position": 1,
"wrappedMessage": <React.Fragment>
error 1
</React.Fragment>,
}
}
/>,
"severity": "error",
"shortMessage": "Layer 1 error: ",
},
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": true,
"longMessage": <FormattedMessage
defaultMessage="Layer {position} error: {wrappedMessage}"
id="xpack.lens.indexPattern.layerErrorWrapper"
values={
Object {
"position": 1,
"wrappedMessage": <React.Fragment>
error 2
</React.Fragment>,
}
}
/>,
"severity": "error",
"shortMessage": "Layer 1 error: ",
},
]
`);
expect(getErrorMessages).toHaveBeenCalledTimes(2);
});
});
describe('warning messages', () => {
let state: FormBasedPrivateState;
let framePublicAPI: FramePublicAPI;
beforeEach(() => {
(getErrorMessages as jest.Mock).mockReturnValueOnce([]);
const termsColumn: TermsIndexPatternColumn = {
operationType: 'terms',
dataType: 'number',
isBucketed: true,
label: '123211',
sourceField: 'foo',
params: {
size: 10,
orderBy: {
type: 'alphabetical',
},
orderDirection: 'asc',
},
};
state = {
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5', 'col6'],
columns: {
col1: {
operationType: 'date_histogram',
params: {
interval: '12h',
},
label: '',
dataType: 'date',
isBucketed: true,
sourceField: 'timestamp',
} as DateHistogramIndexPatternColumn,
col2: {
operationType: 'count',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
col3: {
operationType: 'count',
timeShift: '1h',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
col4: {
operationType: 'count',
timeShift: '13h',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
col5: {
operationType: 'count',
timeShift: '1w',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
col6: {
operationType: 'count',
timeShift: 'previous',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'records',
},
termsCol: termsColumn,
},
},
},
currentIndexPatternId: '1',
};
framePublicAPI = {
activeData: {
first: {
type: 'datatable',
rows: [],
columns: [
{
id: 'col1',
name: 'col1',
meta: {
type: 'date',
source: 'esaggs',
sourceParams: {
type: 'date_histogram',
params: {
used_interval: '12h',
},
},
},
},
},
{
id: 'termsCol',
name: 'termsCol',
meta: {
type: 'string',
source: 'esaggs',
sourceParams: {
type: 'terms',
{
id: 'termsCol',
name: 'termsCol',
meta: {
type: 'string',
source: 'esaggs',
sourceParams: {
type: 'terms',
},
},
},
} as DatatableColumn,
],
} as DatatableColumn,
],
},
},
},
dataViews: {
...createMockFramePublicAPI().dataViews,
indexPatterns: expectedIndexPatterns,
indexPatternRefs: Object.values(expectedIndexPatterns).map(({ id, title }) => ({
id,
title,
})),
},
} as unknown as FramePublicAPI;
});
const extractTranslationIdsFromWarnings = (warnings: React.ReactNode[] | undefined) =>
warnings?.map((item) =>
isFragment(item)
? (item as ReactElement).props.children[0].props.id
: (item as ReactElement).props.id
);
it('should return mismatched time shifts', () => {
const warnings = FormBasedDatasource.getWarningMessages!(state, framePublicAPI, {}, () => {});
expect(extractTranslationIdsFromWarnings(warnings)).toMatchInlineSnapshot(`
Array [
"xpack.lens.indexPattern.timeShiftSmallWarning",
"xpack.lens.indexPattern.timeShiftMultipleWarning",
]
`);
});
it('should show different types of warning messages', () => {
framePublicAPI.activeData!.first.columns[1].meta.sourceParams!.hasPrecisionError = true;
const warnings = FormBasedDatasource.getWarningMessages!(state, framePublicAPI, {}, () => {});
expect(extractTranslationIdsFromWarnings(warnings)).toMatchInlineSnapshot(`
Array [
"xpack.lens.indexPattern.timeShiftSmallWarning",
"xpack.lens.indexPattern.timeShiftMultipleWarning",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled",
]
`);
});
it('should prepend each error with its layer number on multi-layer chart', () => {
(getErrorMessages as jest.Mock).mockClear();
(getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
state = {
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
dataViews: {
...createMockFramePublicAPI().dataViews,
indexPatterns: expectedIndexPatterns,
indexPatternRefs: Object.values(expectedIndexPatterns).map(({ id, title }) => ({
id,
title,
})),
},
second: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
},
currentIndexPatternId: '1',
} as unknown as FramePublicAPI;
});
const extractTranslationIdsFromWarnings = (warnings: UserMessage[]) => {
const onlyWarnings = filterUserMessages(warnings, undefined, { severity: 'warning' });
return onlyWarnings.map(({ longMessage }) =>
isFragment(longMessage)
? (longMessage as ReactElement).props.children[0].props.id
: (longMessage as ReactElement).props.id
);
};
expect(FormBasedDatasource.getErrorMessages(state, indexPatterns)).toEqual([
{ longMessage: 'Layer 1 error: error 1', shortMessage: '' },
{ longMessage: 'Layer 1 error: error 2', shortMessage: '' },
]);
expect(getErrorMessages).toHaveBeenCalledTimes(2);
it('should return mismatched time shifts', () => {
const warnings = FormBasedDatasource.getUserMessages!(state, {
frame: framePublicAPI as FrameDatasourceAPI,
setState: () => {},
});
expect(extractTranslationIdsFromWarnings(warnings)).toMatchInlineSnapshot(`
Array [
"xpack.lens.indexPattern.timeShiftSmallWarning",
"xpack.lens.indexPattern.timeShiftMultipleWarning",
]
`);
});
it('should show different types of warning messages', () => {
framePublicAPI.activeData!.first.columns[1].meta.sourceParams!.hasPrecisionError = true;
const warnings = FormBasedDatasource.getUserMessages!(state, {
frame: framePublicAPI as FrameDatasourceAPI,
setState: () => {},
});
expect(extractTranslationIdsFromWarnings(warnings)).toMatchInlineSnapshot(`
Array [
"xpack.lens.indexPattern.timeShiftSmallWarning",
"xpack.lens.indexPattern.timeShiftMultipleWarning",
"xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled",
]
`);
});
});
});

View file

@ -23,7 +23,7 @@ import { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { EuiButton } from '@elastic/eui';
import type { SharePluginStart } from '@kbn/share-plugin/public';
import type {
DatasourceDimensionEditorProps,
@ -38,6 +38,9 @@ import type {
IndexPatternRef,
DatasourceLayerSettingsProps,
DataSourceInfo,
UserMessage,
FrameDatasourceAPI,
StateSetter,
} from '../../types';
import {
changeIndexPattern,
@ -50,12 +53,7 @@ import {
triggerActionOnIndexPatternChange,
} from './loader';
import { toExpression } from './to_expression';
import {
FormBasedDimensionTrigger,
FormBasedDimensionEditor,
getDropProps,
onDrop,
} from './dimension_panel';
import { FormBasedDimensionEditor, getDropProps, onDrop } from './dimension_panel';
import { FormBasedDataPanel } from './datapanel';
import {
getDatasourceSuggestionsForField,
@ -68,6 +66,7 @@ import {
getFiltersInLayer,
getShardFailuresWarningMessages,
getVisualDefaultsForLayer,
getDeprecatedSamplingWarningMessage,
isColumnInvalid,
cloneLayer,
} from './utils';
@ -101,9 +100,17 @@ import { DOCUMENT_FIELD_NAME } from '../../../common/constants';
import { isColumnOfType } from './operations/definitions/helpers';
import { LayerSettingsPanel } from './layer_settings';
import { FormBasedLayer } from '../..';
import { DimensionTrigger } from '../../shared_components/dimension_trigger';
export type { OperationType, GenericIndexPatternColumn } from './operations';
export { deleteColumn } from './operations';
function wrapOnDot(str?: string) {
// u200B is a non-width white-space character, which allows
// the browser to efficiently word-wrap right after the dot
// without us having to draw a lot of extra DOM elements, etc
return str ? str.replace(/\./g, '.\u200B') : '';
}
export function columnToOperation(
column: GenericIndexPatternColumn,
uniqueLabel?: string,
@ -526,6 +533,8 @@ export function getFormBasedDatasource({
props: DatasourceDimensionTriggerProps<FormBasedPrivateState>
) => {
const columnLabelMap = formBasedDatasource.uniqueLabels(props.state);
const uniqueLabel = columnLabelMap[props.columnId];
const formattedLabel = wrapOnDot(uniqueLabel);
render(
<KibanaThemeProvider theme$={core.theme.theme$}>
@ -542,7 +551,13 @@ export function getFormBasedDatasource({
unifiedSearch,
}}
>
<FormBasedDimensionTrigger uniqueLabel={columnLabelMap[props.columnId]} {...props} />
<DimensionTrigger
id={props.columnId}
label={formattedLabel}
isInvalid={props.invalid}
hideTooltip={props.hideTooltip}
invalidMessage={props.invalidMessage}
/>
</KibanaContextProvider>
</I18nProvider>
</KibanaThemeProvider>,
@ -815,119 +830,58 @@ export function getFormBasedDatasource({
getDatasourceSuggestionsForVisualizeField,
getDatasourceSuggestionsForVisualizeCharts,
getErrorMessages(state, indexPatterns) {
getUserMessages(state, { frame: frameDatasourceAPI, setState }) {
if (!state) {
return;
return [];
}
// Forward the indexpattern as well, as it is required by some operationType checks
const layerErrors = Object.entries(state.layers)
.filter(([_, layer]) => !!indexPatterns[layer.indexPatternId])
.map(([layerId, layer]) =>
(
getErrorMessages(
layer,
indexPatterns[layer.indexPatternId],
state,
layerId,
core,
data
) ?? []
).map((message) => ({
shortMessage: '', // Not displayed currently
longMessage: typeof message === 'string' ? message : message.message,
fixAction: typeof message === 'object' ? message.fixAction : undefined,
}))
);
const layerErrorMessages = getLayerErrorMessages(
state,
frameDatasourceAPI,
setState,
core,
data
);
// Single layer case, no need to explain more
if (layerErrors.length <= 1) {
return layerErrors[0]?.length ? layerErrors[0] : undefined;
}
const dimensionErrorMessages = getDimensionErrorMessages(state, (layerId, columnId) =>
this.isValidColumn(state, frameDatasourceAPI.dataViews.indexPatterns, layerId, columnId)
);
// For multiple layers we will prepend each error with the layer number
const messages = layerErrors.flatMap((errors, index) => {
return errors.map((error) => {
const { shortMessage, longMessage } = error;
return {
shortMessage: shortMessage
? i18n.translate('xpack.lens.indexPattern.layerErrorWrapper', {
defaultMessage: 'Layer {position} error: {wrappedMessage}',
values: {
position: index + 1,
wrappedMessage: shortMessage,
},
})
: '',
longMessage: longMessage
? i18n.translate('xpack.lens.indexPattern.layerErrorWrapper', {
defaultMessage: 'Layer {position} error: {wrappedMessage}',
values: {
position: index + 1,
wrappedMessage: longMessage,
},
})
: '',
const warningMessages = [
...[
...(getStateTimeShiftWarningMessages(
data.datatableUtilities,
state,
frameDatasourceAPI
) || []),
...getPrecisionErrorWarningMessages(
data.datatableUtilities,
state,
frameDatasourceAPI,
core.docLinks,
setState
),
].map((longMessage) => {
const message: UserMessage = {
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage,
};
});
});
return messages.length ? messages : undefined;
},
getWarningMessages: (state, frame, adapters, setState) => {
return [
...(getStateTimeShiftWarningMessages(data.datatableUtilities, state, frame) || []),
...getPrecisionErrorWarningMessages(
data.datatableUtilities,
state,
frame,
core.docLinks,
setState
),
return message;
}),
...getDeprecatedSamplingWarningMessage(core),
];
return [...layerErrorMessages, ...dimensionErrorMessages, ...warningMessages];
},
getSearchWarningMessages: (state, warning, request, response) => {
return [...getShardFailuresWarningMessages(state, warning, request, response, core.theme)];
},
getDeprecationMessages: () => {
const deprecatedMessages: React.ReactNode[] = [];
const useFieldExistenceSamplingKey = 'lens:useFieldExistenceSampling';
const isUsingSampling = core.uiSettings.get(useFieldExistenceSamplingKey);
if (isUsingSampling) {
deprecatedMessages.push(
<EuiCallOut
color="warning"
iconType="alert"
size="s"
title={
<FormattedMessage
id="xpack.lens.indexPattern.useFieldExistenceSamplingBody"
defaultMessage="Field existence sampling has been deprecated and will be removed in Kibana {version}. You may disable this feature in {link}."
values={{
version: '8.6.0',
link: (
<EuiLink
onClick={() => {
core.application.navigateToApp('management', {
path: `/kibana/settings?query=${useFieldExistenceSamplingKey}`,
});
}}
>
<FormattedMessage
id="xpack.lens.indexPattern.useFieldExistenceSampling.advancedSettings"
defaultMessage="Advanced Settings"
/>
</EuiLink>
),
}}
/>
}
/>
);
}
return deprecatedMessages;
},
checkIntegrity: (state, indexPatterns) => {
const ids = Object.values(state.layers || {}).map(({ indexPatternId }) => indexPatternId);
return ids.filter((id) => !indexPatterns[id]);
@ -1024,3 +978,125 @@ function blankLayer(indexPatternId: string, linkToLayers?: string[]): FormBasedL
sampling: 1,
};
}
function getLayerErrorMessages(
state: FormBasedPrivateState,
frameDatasourceAPI: FrameDatasourceAPI,
setState: StateSetter<FormBasedPrivateState, unknown>,
core: CoreStart,
data: DataPublicPluginStart
) {
const indexPatterns = frameDatasourceAPI.dataViews.indexPatterns;
const layerErrors: UserMessage[][] = Object.entries(state.layers)
.filter(([_, layer]) => !!indexPatterns[layer.indexPatternId])
.map(([layerId, layer]) =>
(
getErrorMessages(layer, indexPatterns[layer.indexPatternId], state, layerId, core, data) ??
[]
).map((error) => {
const message: UserMessage = {
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: '',
longMessage:
typeof error === 'string' ? (
error
) : (
<>
{error.message}
{error.fixAction && (
<EuiButton
data-test-subj="errorFixAction"
onClick={async () => {
const newState = await error.fixAction?.newState(frameDatasourceAPI);
if (newState) {
setState(newState);
}
}}
>
{error.fixAction?.label}
</EuiButton>
)}
</>
),
};
return message;
})
);
let errorMessages: UserMessage[];
if (layerErrors.length <= 1) {
// 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={{
position: index + 1,
wrappedMessage: <>{error.longMessage}</>,
}}
/>
),
};
return message;
});
});
}
return errorMessages;
}
function getDimensionErrorMessages(
state: FormBasedPrivateState,
isValidColumn: (layerId: string, columnId: string) => boolean
) {
// generate messages for invalid columns
const columnErrorMessages: UserMessage[] = Object.keys(state.layers)
.map((layerId) => {
const messages: UserMessage[] = [];
for (const columnId of Object.keys(state.layers[layerId].columns)) {
if (!isValidColumn(layerId, columnId)) {
messages.push({
severity: 'error',
displayLocations: [{ id: 'dimensionTrigger', dimensionId: columnId }],
fixableInEditor: true,
shortMessage: '',
longMessage: (
<p>
{i18n.translate('xpack.lens.configure.invalidConfigTooltip', {
defaultMessage: 'Invalid configuration.',
})}
<br />
{i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', {
defaultMessage: 'Click for more details.',
})}
</p>
),
});
}
}
return messages;
})
.flat();
return columnErrorMessages;
}

View file

@ -8,10 +8,10 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { DocLinksStart, ThemeServiceStart } from '@kbn/core/public';
import type { CoreStart, DocLinksStart, ThemeServiceStart } from '@kbn/core/public';
import type { DatatableUtilitiesService } from '@kbn/data-plugin/common';
import { TimeRange } from '@kbn/es-query';
import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import { EuiCallOut, EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import { groupBy, escape, uniq } from 'lodash';
@ -26,7 +26,7 @@ import {
import { estypes } from '@elastic/elasticsearch';
import type { DateRange } from '../../../common/types';
import type { FramePublicAPI, IndexPattern, StateSetter } from '../../types';
import type { FramePublicAPI, IndexPattern, StateSetter, UserMessage } from '../../types';
import { renewIDs } from '../../utils';
import type { FormBasedLayer, FormBasedPersistedState, FormBasedPrivateState } from './types';
import type { ReferenceBasedIndexPatternColumn } from './operations/definitions/column_types';
@ -186,7 +186,7 @@ export function getShardFailuresWarningMessages(
request: SearchRequest,
response: estypes.SearchResponse,
theme: ThemeServiceStart
): Array<string | React.ReactNode> {
): UserMessage[] {
if (state) {
if (warning.type === 'shard_failure') {
switch (warning.reason.type) {
@ -205,38 +205,55 @@ export function getShardFailuresWarningMessages(
].includes(col.operationType)
)
.map((col) => col.label)
).map((label) =>
i18n.translate('xpack.lens.indexPattern.tsdbRollupWarning', {
defaultMessage:
'{label} uses a function that is unsupported by rolled up data. Select a different function or change the time range.',
values: {
label,
},
})
).map(
(label) =>
({
uniqueId: `unsupported_aggregation_on_downsampled_index--${label}`,
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }, { id: 'embeddableBadge' }],
shortMessage: '',
longMessage: i18n.translate('xpack.lens.indexPattern.tsdbRollupWarning', {
defaultMessage:
'{label} uses a function that is unsupported by rolled up data. Select a different function or change the time range.',
values: {
label,
},
}),
} as UserMessage)
)
);
default:
return [
<>
<EuiText size="s">
<strong>{warning.message}</strong>
<p>{warning.text}</p>
</EuiText>
<EuiSpacer size="s" />
{warning.text ? (
<ShardFailureOpenModalButton
theme={theme}
title={warning.message}
size="m"
getRequestMeta={() => ({
request: request as ShardFailureRequest,
response,
})}
color="primary"
isButtonEmpty={true}
/>
) : null}
</>,
{
uniqueId: `shard_failure`,
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }, { id: 'embeddableBadge' }],
shortMessage: '',
longMessage: (
<>
<EuiText size="s">
<strong>{warning.message}</strong>
<p>{warning.text}</p>
</EuiText>
<EuiSpacer size="s" />
{warning.text ? (
<ShardFailureOpenModalButton
theme={theme}
title={warning.message}
size="m"
getRequestMeta={() => ({
request: request as ShardFailureRequest,
response,
})}
color="primary"
isButtonEmpty={true}
/>
) : null}
</>
),
} as UserMessage,
];
}
}
@ -376,6 +393,52 @@ export function getPrecisionErrorWarningMessages(
return warningMessages;
}
export function getDeprecatedSamplingWarningMessage(core: CoreStart): UserMessage[] {
const useFieldExistenceSamplingKey = 'lens:useFieldExistenceSampling';
const isUsingSampling = core.uiSettings.get(useFieldExistenceSamplingKey);
return isUsingSampling
? [
{
severity: 'warning',
fixableInEditor: false,
displayLocations: [{ id: 'banner' }],
shortMessage: '',
longMessage: (
<EuiCallOut
color="warning"
iconType="alert"
size="s"
title={
<FormattedMessage
id="xpack.lens.indexPattern.useFieldExistenceSamplingBody"
defaultMessage="Field existence sampling has been deprecated and will be removed in Kibana {version}. You may disable this feature in {link}."
values={{
version: '8.6.0',
link: (
<EuiLink
onClick={() => {
core.application.navigateToApp('management', {
path: `/kibana/settings?query=${useFieldExistenceSamplingKey}`,
});
}}
>
<FormattedMessage
id="xpack.lens.indexPattern.useFieldExistenceSampling.advancedSettings"
defaultMessage="Advanced Settings"
/>
</EuiLink>
),
}}
/>
}
/>
),
},
]
: [];
}
export function getVisualDefaultsForLayer(layer: FormBasedLayer) {
return Object.keys(layer.columns).reduce<Record<string, Record<string, unknown>>>(
(memo, columnId) => {

View file

@ -13,7 +13,7 @@ import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { getTextBasedDatasource } from './text_based_languages';
import { generateId } from '../../id_generator';
import { DatasourcePublicAPI, Datasource } from '../../types';
import { DatasourcePublicAPI, Datasource, FrameDatasourceAPI } from '../../types';
jest.mock('../../id_generator');
@ -496,8 +496,8 @@ describe('Textbased Data Source', () => {
});
});
describe('#getErrorMessages', () => {
it('should use the results of getErrorMessages directly when single layer', () => {
describe('#getUserMessages', () => {
it('should use the results of getUserMessages directly when single layer', () => {
const state = {
layers: {
a: {
@ -539,10 +539,43 @@ describe('Textbased Data Source', () => {
},
},
} as unknown as TextBasedPrivateState;
expect(TextBasedDatasource.getErrorMessages(state, indexPatterns)).toEqual([
{ longMessage: 'error 1', shortMessage: 'error 1' },
{ longMessage: 'error 2', shortMessage: 'error 2' },
]);
expect(
TextBasedDatasource.getUserMessages(state, {
frame: { dataViews: indexPatterns } as unknown as FrameDatasourceAPI,
setState: () => {},
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
Object {
"id": "textBasedLanguagesQueryInput",
},
],
"fixableInEditor": true,
"longMessage": "error 1",
"severity": "error",
"shortMessage": "error 1",
},
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
Object {
"id": "textBasedLanguagesQueryInput",
},
],
"fixableInEditor": true,
"longMessage": "error 2",
"severity": "error",
"shortMessage": "error 2",
},
]
`);
});
});

View file

@ -28,6 +28,7 @@ import {
TableChangeType,
DatasourceDimensionTriggerProps,
DataSourceInfo,
UserMessage,
} from '../../types';
import { generateId } from '../../id_generator';
import { toExpression } from './to_expression';
@ -164,7 +165,7 @@ export function getTextBasedDatasource({
checkIntegrity: () => {
return [];
},
getErrorMessages: (state) => {
getUserMessages: (state) => {
const errors: Error[] = [];
Object.values(state.layers).forEach((layer) => {
@ -173,22 +174,16 @@ export function getTextBasedDatasource({
}
});
return errors.map((err) => {
return {
const message: UserMessage = {
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }, { id: 'textBasedLanguagesQueryInput' }],
shortMessage: err.message,
longMessage: err.message,
};
return message;
});
},
getUnifiedSearchErrors: (state) => {
const errors: Error[] = [];
Object.values(state.layers).forEach((layer) => {
if (layer.errors && layer.errors.length > 0) {
errors.push(...layer.errors);
}
});
return errors;
},
initialize(
state?: TextBasedPersistedState,
savedObjectReferences?,

View file

@ -43,7 +43,7 @@ export function DimensionButton({
onClick={() => onClick(accessorConfig.columnId)}
aria-label={triggerLinkA11yText(label)}
title={triggerLinkA11yText(label)}
color={invalid || group.invalid ? 'danger' : undefined}
color={invalid ? 'danger' : undefined}
>
<ColorIndicator accessorConfig={accessorConfig}>{children}</ColorIndicator>
</EuiLink>

View file

@ -135,6 +135,7 @@ describe('ConfigPanel', () => {
isFullscreen: false,
toggleFullscreen: jest.fn(),
uiActions,
getUserMessages: () => [],
};
}

View file

@ -113,6 +113,7 @@ describe('LayerPanel', () => {
onEmptyDimensionAdd: jest.fn(),
onChangeIndexPattern: jest.fn(),
indexPatternService: createIndexPatternServiceMock(),
getUserMessages: () => [],
};
}
@ -1363,5 +1364,7 @@ describe('LayerPanel', () => {
expect(mockDatasource.renderDimensionTrigger).not.toHaveBeenCalled();
expect(mockVisualization.renderDimensionTrigger).toHaveBeenCalled();
});
// TODO - test user message display
});
});

View file

@ -32,6 +32,7 @@ import {
isOperation,
LayerAction,
VisualizationDimensionGroupConfig,
UserMessagesGetter,
} from '../../../types';
import { DragDropIdentifier, ReorderProvider } from '../../../drag_drop';
import { LayerSettings } from './layer_settings';
@ -91,6 +92,7 @@ export function LayerPanel(
visualizationId?: string;
}) => void;
indexPatternService: IndexPatternServiceAPI;
getUserMessages: UserMessagesGetter;
}
) {
const [activeDimension, setActiveDimension] = useState<ActiveDimensionState>(
@ -509,6 +511,18 @@ export function LayerPanel(
<ReorderProvider id={group.groupId} className={'lnsLayerPanel__group'}>
{group.accessors.map((accessorConfig, accessorIndex) => {
const { columnId } = accessorConfig;
const messages = props.getUserMessages('dimensionTrigger', {
// TODO - support warnings
severity: 'error',
dimensionId: columnId,
});
const hasMessages = Boolean(messages.length);
const messageToDisplay = hasMessages
? messages[0].shortMessage || messages[0].longMessage
: undefined;
return (
<DraggableDimensionButton
activeVisualization={activeVisualization}
@ -580,8 +594,8 @@ export function LayerPanel(
groupId: group.groupId,
filterOperations: group.filterOperations,
hideTooltip,
invalid: group.invalid,
invalidMessage: group.invalidMessage,
invalid: hasMessages,
invalidMessage: messageToDisplay,
indexPatterns: dataViews.indexPatterns,
}}
/>
@ -591,13 +605,8 @@ export function LayerPanel(
columnId,
label: columnLabelMap?.[columnId] ?? '',
hideTooltip,
...(activeVisualization?.validateColumn?.(
visualizationState,
{ dataViews },
layerId,
columnId,
group
) || { invalid: false }),
invalid: hasMessages,
invalidMessage: messageToDisplay,
})}
</>
)}

View file

@ -15,6 +15,7 @@ import {
VisualizationDimensionGroupConfig,
DatasourceMap,
VisualizationMap,
UserMessagesGetter,
} from '../../../types';
export interface ConfigPanelWrapperProps {
framePublicAPI: FramePublicAPI;
@ -23,6 +24,7 @@ export interface ConfigPanelWrapperProps {
core: DatasourceDimensionEditorProps['core'];
indexPatternService: IndexPatternServiceAPI;
uiActions: UiActionsStart;
getUserMessages: UserMessagesGetter;
}
export interface LayerPanelProps {

View file

@ -99,6 +99,8 @@ function getDefaultProps() {
lensInspector: getLensInspectorService(inspectorPluginMock.createStartContract()),
showNoDataPopover: jest.fn(),
indexPatternService: createIndexPatternServiceMock(),
getUserMessages: () => [],
addUserMessages: () => () => {},
};
return defaultProps;
}

View file

@ -5,11 +5,18 @@
* 2.0.
*/
import React, { useCallback, useRef, useMemo } from 'react';
import React, { useCallback, useRef } from 'react';
import { CoreStart } from '@kbn/core/public';
import { ReactExpressionRendererType } from '@kbn/expressions-plugin/public';
import { trackUiCounterEvents } from '../../lens_ui_telemetry';
import { DatasourceMap, FramePublicAPI, VisualizationMap, Suggestion } from '../../types';
import {
DatasourceMap,
FramePublicAPI,
VisualizationMap,
Suggestion,
UserMessagesGetter,
AddUserMessages,
} from '../../types';
import { DataPanelWrapper } from './data_panel_wrapper';
import { BannerWrapper } from './banner_wrapper';
import { ConfigPanelWrapper } from './config_panel';
@ -41,6 +48,8 @@ export interface EditorFrameProps {
showNoDataPopover: () => void;
lensInspector: LensInspector;
indexPatternService: IndexPatternServiceAPI;
getUserMessages: UserMessagesGetter;
addUserMessages: AddUserMessages;
}
export function EditorFrame(props: EditorFrameProps) {
@ -96,21 +105,15 @@ export function EditorFrame(props: EditorFrameProps) {
showMemoizedErrorNotification(error);
}, []);
const bannerMessages: React.ReactNode[] | undefined = useMemo(() => {
if (activeDatasourceId) {
return datasourceMap[activeDatasourceId].getDeprecationMessages?.(
datasourceStates[activeDatasourceId].state
);
}
}, [activeDatasourceId, datasourceMap, datasourceStates]);
const bannerMessages = props.getUserMessages('banner', { severity: 'warning' });
return (
<RootDragDropProvider>
<FrameLayout
bannerMessages={
bannerMessages ? (
bannerMessages.length ? (
<ErrorBoundary onError={onError}>
<BannerWrapper nodes={bannerMessages} />
<BannerWrapper nodes={bannerMessages.map(({ longMessage }) => longMessage)} />
</ErrorBoundary>
) : undefined
}
@ -139,6 +142,7 @@ export function EditorFrame(props: EditorFrameProps) {
framePublicAPI={framePublicAPI}
uiActions={props.plugins.uiActions}
indexPatternService={props.indexPatternService}
getUserMessages={props.getUserMessages}
/>
</ErrorBoundary>
)
@ -156,6 +160,8 @@ export function EditorFrame(props: EditorFrameProps) {
visualizationMap={visualizationMap}
framePublicAPI={framePublicAPI}
getSuggestionForField={getSuggestionForField.current}
getUserMessages={props.getUserMessages}
addUserMessages={props.addUserMessages}
/>
</ErrorBoundary>
)
@ -169,6 +175,7 @@ export function EditorFrame(props: EditorFrameProps) {
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
frame={framePublicAPI}
getUserMessages={props.getUserMessages}
/>
</ErrorBoundary>
)

View file

@ -18,26 +18,16 @@ import {
Datasource,
DatasourceLayers,
DatasourceMap,
FramePublicAPI,
IndexPattern,
IndexPatternMap,
IndexPatternRef,
InitializationOptions,
Visualization,
VisualizationMap,
VisualizeEditorContext,
} from '../../types';
import { buildExpression } from './expression_helpers';
import { showMemoizedErrorNotification } from '../../lens_ui_errors';
import { Document } from '../../persistence/saved_object_store';
import { getActiveDatasourceIdFromDoc, sortDataViewRefs } from '../../utils';
import type { ErrorMessage } from '../types';
import {
getMissingCurrentDatasource,
getMissingIndexPatterns,
getMissingVisualizationTypeError,
getUnknownVisualizationTypeError,
} from '../error_helper';
import type { DatasourceStates, DataViewsState, VisualizationState } from '../../state_management';
import { readFromStorage } from '../../settings_storage';
import { loadIndexPatternRefs, loadIndexPatterns } from '../../data_views_service/loader';
@ -325,7 +315,11 @@ export async function persistedStateToExpression(
dataViews: DataViewsContract;
timefilter: TimefilterContract;
}
): Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }> {
): Promise<{
ast: Ast | null;
indexPatterns: IndexPatternMap;
indexPatternRefs: IndexPatternRef[];
}> {
const {
state: {
visualization: persistedVisualizationState,
@ -339,16 +333,7 @@ export async function persistedStateToExpression(
description,
} = doc;
if (!visualizationType) {
return {
ast: null,
errors: [{ shortMessage: '', longMessage: getMissingVisualizationTypeError() }],
};
}
if (!visualizations[visualizationType]) {
return {
ast: null,
errors: [getUnknownVisualizationTypeError(visualizationType)],
};
return { ast: null, indexPatterns: {}, indexPatternRefs: [] };
}
const visualization = visualizations[visualizationType!];
const visualizationState = initializeVisualization({
@ -391,30 +376,11 @@ export async function persistedStateToExpression(
if (datasourceId == null) {
return {
ast: null,
errors: [{ shortMessage: '', longMessage: getMissingCurrentDatasource() }],
indexPatterns,
indexPatternRefs,
};
}
const indexPatternValidation = validateRequiredIndexPatterns(
datasourceMap[datasourceId],
datasourceStates[datasourceId],
indexPatterns
);
if (indexPatternValidation) {
return {
ast: null,
errors: indexPatternValidation,
};
}
const validationResult = validateDatasourceAndVisualization(
datasourceMap[datasourceId],
datasourceStates[datasourceId].state,
visualization,
visualizationState,
{ datasourceLayers, dataViews: { indexPatterns } as DataViewsState }
);
const currentTimeRange = services.timefilter.getAbsoluteTime();
return {
@ -429,7 +395,8 @@ export async function persistedStateToExpression(
indexPatterns,
dateRange: { fromDate: currentTimeRange.from, toDate: currentTimeRange.to },
}),
errors: validationResult,
indexPatterns,
indexPatternRefs,
};
}
@ -438,7 +405,7 @@ export function getMissingIndexPattern(
currentDatasourceState: { state: unknown } | null,
indexPatterns: IndexPatternMap
) {
if (currentDatasourceState == null || currentDatasource == null) {
if (currentDatasourceState?.state == null || currentDatasource == null) {
return [];
}
const missingIds = currentDatasource.checkIntegrity(currentDatasourceState.state, indexPatterns);
@ -447,55 +414,3 @@ export function getMissingIndexPattern(
}
return missingIds;
}
const validateRequiredIndexPatterns = (
currentDatasource: Datasource,
currentDatasourceState: { state: unknown } | null,
indexPatterns: IndexPatternMap
): ErrorMessage[] | undefined => {
const missingIds = getMissingIndexPattern(
currentDatasource,
currentDatasourceState,
indexPatterns
);
if (!missingIds.length) {
return;
}
return [{ shortMessage: '', longMessage: getMissingIndexPatterns(missingIds), type: 'fixable' }];
};
export const validateDatasourceAndVisualization = (
currentDataSource: Datasource | null,
currentDatasourceState: unknown | null,
currentVisualization: Visualization | null,
currentVisualizationState: unknown | undefined,
frame: Pick<FramePublicAPI, 'datasourceLayers' | 'dataViews'>
): ErrorMessage[] | undefined => {
try {
const datasourceValidationErrors = currentDatasourceState
? currentDataSource?.getErrorMessages(currentDatasourceState, frame.dataViews.indexPatterns)
: undefined;
const visualizationValidationErrors = currentVisualizationState
? currentVisualization?.getErrorMessages(currentVisualizationState, frame)
: undefined;
if (datasourceValidationErrors?.length || visualizationValidationErrors?.length) {
return [...(datasourceValidationErrors || []), ...(visualizationValidationErrors || [])];
}
} catch (e) {
showMemoizedErrorNotification(e);
if (e.message) {
return [
{
shortMessage: e.message,
longMessage: e.message,
type: 'critical',
},
];
}
}
return undefined;
};

View file

@ -104,6 +104,7 @@ describe('suggestion_panel', () => {
},
ExpressionRenderer: expressionRendererMock,
frame: createMockFramePublicAPI(),
getUserMessages: () => [],
};
});

View file

@ -38,15 +38,13 @@ import {
DatasourceMap,
VisualizationMap,
DatasourceLayers,
UserMessagesGetter,
FrameDatasourceAPI,
} from '../../types';
import { getSuggestions, switchToSuggestion } from './suggestion_helpers';
import { getDatasourceExpressionsByLayers } from './expression_helpers';
import { showMemoizedErrorNotification } from '../../lens_ui_errors/memoized_error_notification';
import {
getMissingIndexPattern,
validateDatasourceAndVisualization,
getDatasourceLayers,
} from './state_helpers';
import { getMissingIndexPattern } from './state_helpers';
import {
rollbackSuggestion,
selectExecutionContextSearch,
@ -63,16 +61,45 @@ import {
selectChangesApplied,
applyChanges,
selectStagedActiveData,
selectFrameDatasourceAPI,
} from '../../state_management';
import { filterUserMessages } from '../../app_plugin/get_application_user_messages';
const MAX_SUGGESTIONS_DISPLAYED = 5;
const LOCAL_STORAGE_SUGGESTIONS_PANEL = 'LENS_SUGGESTIONS_PANEL_HIDDEN';
const configurationsValid = (
currentDataSource: Datasource | null,
currentDatasourceState: unknown,
currentVisualization: Visualization,
currentVisualizationState: unknown,
frame: FrameDatasourceAPI
): boolean => {
try {
return (
filterUserMessages(
[
...(currentDataSource?.getUserMessages?.(currentDatasourceState, {
frame,
setState: () => {},
}) ?? []),
...(currentVisualization?.getUserMessages?.(currentVisualizationState, { frame }) ?? []),
],
undefined,
{ severity: 'error' }
).length === 0
);
} catch (e) {
return false;
}
};
export interface SuggestionPanelProps {
datasourceMap: DatasourceMap;
visualizationMap: VisualizationMap;
ExpressionRenderer: ReactExpressionRendererType;
frame: FramePublicAPI;
getUserMessages: UserMessagesGetter;
}
const PreviewRenderer = ({
@ -189,6 +216,7 @@ export function SuggestionPanel({
visualizationMap,
frame,
ExpressionRenderer: ExpressionRendererComponent,
getUserMessages,
}: SuggestionPanelProps) {
const dispatchLens = useLensDispatch();
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
@ -197,6 +225,10 @@ export function SuggestionPanel({
const existsStagedPreview = useLensSelector((state) => Boolean(state.lens.stagedPreview));
const currentVisualization = useLensSelector(selectCurrentVisualization);
const currentDatasourceStates = useLensSelector(selectCurrentDatasourceStates);
const frameDatasourceAPI = useLensSelector((state) =>
selectFrameDatasourceAPI(state, datasourceMap)
);
const changesApplied = useLensSelector(selectChangesApplied);
// get user's selection from localStorage, this key defines if the suggestions panel will be hidden or not
const [hideSuggestions, setHideSuggestions] = useLocalStorage(
@ -237,28 +269,13 @@ export function SuggestionPanel({
}) => {
return (
!hide &&
validateDatasourceAndVisualization(
configurationsValid(
suggestionDatasourceId ? datasourceMap[suggestionDatasourceId] : null,
suggestionDatasourceState,
visualizationMap[visualizationId],
suggestionVisualizationState,
{
...frame,
dataViews: frame.dataViews,
datasourceLayers: getDatasourceLayers(
suggestionDatasourceId
? {
[suggestionDatasourceId]: {
isLoading: true,
state: suggestionDatasourceState,
},
}
: {},
datasourceMap,
frame.dataViews.indexPatterns
),
}
) == null
frameDatasourceAPI
)
);
}
)
@ -274,16 +291,11 @@ export function SuggestionPanel({
),
}));
const validationErrors = validateDatasourceAndVisualization(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId && currentDatasourceStates[activeDatasourceId]?.state,
currentVisualization.activeId ? visualizationMap[currentVisualization.activeId] : null,
currentVisualization.state,
frame
);
const hasErrors =
getUserMessages(['visualization', 'visualizationInEditor'], { severity: 'error' }).length > 0;
const newStateExpression =
currentVisualization.state && currentVisualization.activeId && !validationErrors
currentVisualization.state && currentVisualization.activeId && !hasErrors
? preparePreviewExpression(
{ visualizationState: currentVisualization.state },
visualizationMap[currentVisualization.activeId],
@ -296,7 +308,7 @@ export function SuggestionPanel({
return {
suggestions: newSuggestions,
currentStateExpression: newStateExpression,
currentStateError: validationErrors,
currentStateError: hasErrors,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@ -387,7 +399,7 @@ export function SuggestionPanel({
{currentVisualization.activeId && !hideSuggestions && (
<SuggestionPreview
preview={{
error: currentStateError != null,
error: currentStateError,
expression: currentStateExpression,
icon:
visualizationMap[currentVisualization.activeId].getDescription(

View file

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`workspace_panel should show an error message if the expression fails to parse 1`] = `
Array [
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": true,
"longMessage": <React.Fragment>
<p
data-test-subj="expression-failure"
>
<FormattedMessage
defaultMessage="An error occurred in the expression"
id="xpack.lens.editorFrame.expressionFailure"
values={Object {}}
/>
</p>
<p>
Error: Unable to parse expression: Expected "/*", "//", [ ,\\t,\\r,\\n], or function but "|" found.
</p>
</React.Fragment>,
"severity": "error",
"shortMessage": "An unexpected error occurred while preparing the chart",
"uniqueId": "expression_build_error",
},
],
]
`;

View file

@ -8,7 +8,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { ReactExpressionRendererProps } from '@kbn/expressions-plugin/public';
import { FramePublicAPI, Visualization } from '../../../types';
import { FramePublicAPI, UserMessage, Visualization } from '../../../types';
import {
createMockVisualization,
createMockDatasource,
@ -61,6 +61,7 @@ function createCoreStartWithPermissions(newCapabilities = defaultPermissions) {
const defaultProps = {
datasourceMap: {},
visualizationMap: {},
framePublicAPI: createMockFramePublicAPI(),
ExpressionRenderer: createExpressionRendererMock(),
core: createCoreStartWithPermissions(),
@ -71,6 +72,8 @@ const defaultProps = {
getSuggestionForField: () => undefined,
lensInspector: getLensInspectorService(inspectorPluginMock.createStartContract()),
toggleFullscreen: jest.fn(),
getUserMessages: () => [],
addUserMessages: () => () => {},
};
const toExpr = (
@ -309,15 +312,6 @@ describe('workspace_panel', () => {
});
it('should base saveability on working changes when auto-apply disabled', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockVisualization.getErrorMessages.mockImplementation((currentVisualizationState: any) => {
if (currentVisualizationState.hasProblem) {
return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }];
} else {
return [];
}
});
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
@ -325,9 +319,12 @@ describe('workspace_panel', () => {
mockDatasource.toExpression.mockReturnValue('datasource');
mockDatasource.getLayers.mockReturnValue(['first']);
let userMessages: UserMessage[] = [];
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
getUserMessages={() => userMessages}
datasourceMap={{
testDatasource: mockDatasource,
}}
@ -355,11 +352,24 @@ describe('workspace_panel', () => {
`);
expect(isSaveable()).toBe(true);
// note that populating the user messages and then dispatching a state update is true to
// how Lens interacts with the workspace panel from its perspective. all the panel does is call
// that getUserMessages function any time it gets re-rendered (aka state update)
userMessages = [
{
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: 'hey there',
longMessage: "i'm another error",
},
] as UserMessage[];
act(() => {
mounted.lensStore.dispatch(
updateVisualizationState({
visualizationId: 'testVis',
newState: { activeId: 'testVis', hasProblem: true },
newState: {},
})
);
});
@ -564,7 +574,6 @@ describe('workspace_panel', () => {
type: 'lens/onActiveDataChange',
payload: {
activeData: tablesData,
requestWarnings: [],
},
});
});
@ -662,92 +671,25 @@ describe('workspace_panel', () => {
expect(expressionRendererMock).toHaveBeenCalledTimes(3);
});
it('should show an error message if there are missing indexpatterns in the visualization', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource.checkIntegrity.mockReturnValue(['a']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceMap={{
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
/>,
it('should show configuration error messages if present', async () => {
const messages: UserMessage[] = [
{
preloadedState: {
datasourceStates: {
testDatasource: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
},
},
}
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="missing-refs-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should not show the management action in case of missing indexpattern and no navigation permissions', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceMap={{
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
// Use cannot navigate to the management page
core={createCoreStartWithPermissions({
navLinks: { management: false },
management: { kibana: { indexPatterns: true } },
})}
/>,
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: 'hey there',
longMessage: "i'm an error",
},
{
preloadedState: {
datasourceStates: {
testDatasource: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
},
},
}
);
instance = mounted.instance;
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: 'hey there',
longMessage: "i'm another error",
},
];
expect(
instance.find('[data-test-subj="configuration-failure-reconfigure-indexpatterns"]').exists()
).toBeFalsy();
});
it('should not show the management action in case of missing indexpattern and no indexPattern specific permissions', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
const getUserMessages = jest.fn(() => messages);
const mounted = await mountWithProvider(
<WorkspacePanel
@ -755,167 +697,35 @@ describe('workspace_panel', () => {
datasourceMap={{
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
// user can go to management, but indexPatterns management is not accessible
core={createCoreStartWithPermissions({
navLinks: { management: true },
management: { kibana: { indexPatterns: false } },
})}
/>,
{
preloadedState: {
datasourceStates: {
testDatasource: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
},
},
}
);
instance = mounted.instance;
expect(
instance.find('[data-test-subj="configuration-failure-reconfigure-indexpatterns"]').exists()
).toBeFalsy();
});
it('should show an error message if validation on datasource does not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
]);
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceMap={{
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
/>
);
instance = mounted.instance;
act(() => {
instance.update();
});
expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should show an error message if validation on visualization does not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue(undefined);
mockDatasource.getLayers.mockReturnValue(['first']);
mockVisualization.getErrorMessages.mockReturnValue([
{ shortMessage: 'Some error happened', longMessage: 'Some long description happened' },
]);
mockVisualization.toExpression.mockReturnValue('testVis');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceMap={{
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
testVis: mockVisualization,
}}
/>
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
it('should show an error message if validation on both datasource and visualization do not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
]);
mockDatasource.getLayers.mockReturnValue(['first']);
mockVisualization.getErrorMessages.mockReturnValue([
{ shortMessage: 'Some error happened', longMessage: 'Some long description happened' },
]);
mockVisualization.toExpression.mockReturnValue('testVis');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceMap={{
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
testVis: mockVisualization,
}}
getUserMessages={getUserMessages}
/>
);
instance = mounted.instance;
// EuiFlexItem duplicates internally the attribute, so we need to filter only the most inner one here
expect(
instance.find('[data-test-subj="configuration-failure-more-errors"]').last().text()
).toEqual(' +1 error');
expect(instance.find('[data-test-subj="workspace-more-errors-button"]').last().text()).toEqual(
' +1 error'
);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
expect(getUserMessages).toHaveBeenCalledWith(['visualization', 'visualizationInEditor'], {
severity: 'error',
});
});
it('should NOT display errors for unapplied changes', async () => {
it('should NOT display config errors for unapplied changes', async () => {
// this test is important since we don't want the workspace panel to
// display errors if the user has disabled auto-apply, messed something up,
// but not yet applied their changes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockDatasource.getErrorMessages.mockImplementation((currentDatasourceState: any) => {
if (currentDatasourceState.hasProblem) {
return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }];
} else {
return [];
}
});
mockDatasource.getLayers.mockReturnValue(['first']);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockVisualization.getErrorMessages.mockImplementation((currentVisualizationState: any) => {
if (currentVisualizationState.hasProblem) {
return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }];
} else {
return [];
}
});
mockVisualization.toExpression.mockReturnValue('testVis');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
let userMessages = [] as UserMessage[];
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
getUserMessages={() => userMessages}
datasourceMap={{
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
testVis: mockVisualization,
}}
@ -925,9 +735,7 @@ describe('workspace_panel', () => {
instance = mounted.instance;
const lensStore = mounted.lensStore;
const showingErrors = () =>
instance.exists('[data-test-subj="configuration-failure-error"]') ||
instance.exists('[data-test-subj="configuration-failure-more-errors"]');
const showingErrors = () => instance.exists('[data-test-subj="workspace-error-message"]');
expect(showingErrors()).toBeFalsy();
@ -939,27 +747,24 @@ describe('workspace_panel', () => {
expect(showingErrors()).toBeFalsy();
// introduce some issues
userMessages = [
{
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: 'hey there',
longMessage: "i'm another error",
},
] as UserMessage[];
act(() => {
lensStore.dispatch(
updateDatasourceState({
datasourceId: 'testDatasource',
updater: { hasProblem: true },
updater: {},
})
);
});
instance.update();
expect(showingErrors()).toBeFalsy();
act(() => {
lensStore.dispatch(
updateVisualizationState({
visualizationId: 'testVis',
newState: { activeId: 'testVis', hasProblem: true },
})
);
});
instance.update();
expect(showingErrors()).toBeFalsy();
@ -970,6 +775,7 @@ describe('workspace_panel', () => {
expect(showingErrors()).toBeTruthy();
});
// TODO - test refresh after expression failure error
it('should show an error message if the expression fails to parse', async () => {
mockDatasource.toExpression.mockReturnValue('|||');
mockDatasource.getLayers.mockReturnValue(['first']);
@ -978,6 +784,10 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
const mockRemoveUserMessages = jest.fn();
const mockAddUserMessages = jest.fn(() => mockRemoveUserMessages);
const mockGetUserMessages = jest.fn<UserMessage[], unknown[]>(() => []);
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
@ -988,11 +798,13 @@ describe('workspace_panel', () => {
visualizationMap={{
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
addUserMessages={mockAddUserMessages}
getUserMessages={mockGetUserMessages}
/>
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="expression-failure"]').exists()).toBeTruthy();
expect(mockAddUserMessages.mock.lastCall).toMatchSnapshot();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});

View file

@ -18,13 +18,11 @@ import {
EuiText,
EuiButtonEmpty,
EuiLink,
EuiButton,
EuiSpacer,
EuiTextColor,
EuiSpacer,
} from '@elastic/eui';
import type { CoreStart } from '@kbn/core/public';
import type { DataPublicPluginStart, ExecutionContextSearch } from '@kbn/data-plugin/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type {
ExpressionRendererEvent,
ExpressionRenderError,
@ -44,9 +42,12 @@ import {
isLensEditEvent,
VisualizationMap,
DatasourceMap,
DatasourceFixAction,
Suggestion,
DatasourceLayers,
UserMessage,
UserMessagesGetter,
AddUserMessages,
isMessageRemovable,
} from '../../../types';
import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop';
import { switchToSuggestion } from '../suggestion_helpers';
@ -54,16 +55,11 @@ import { buildExpression } from '../expression_helpers';
import { WorkspacePanelWrapper } from './workspace_panel_wrapper';
import applyChangesIllustrationDark from '../../../assets/render_dark@2x.png';
import applyChangesIllustrationLight from '../../../assets/render_light@2x.png';
import {
getOriginalRequestErrorMessages,
getUnknownVisualizationTypeError,
} from '../../error_helper';
import { getMissingIndexPattern, validateDatasourceAndVisualization } from '../state_helpers';
import { getOriginalRequestErrorMessages } from '../../error_helper';
import {
onActiveDataChange,
useLensDispatch,
editVisualizationAction,
updateDatasourceState,
setSaveable,
useLensSelector,
selectExecutionContext,
@ -94,14 +90,11 @@ export interface WorkspacePanelProps {
plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart };
getSuggestionForField: (field: DragDropIdentifier) => Suggestion | undefined;
lensInspector: LensInspector;
getUserMessages: UserMessagesGetter;
addUserMessages: AddUserMessages;
}
interface WorkspaceState {
expressionBuildError?: Array<{
shortMessage: string;
longMessage: React.ReactNode;
fixAction?: DatasourceFixAction<unknown>;
}>;
expandError: boolean;
expressionToRender: string | null | undefined;
}
@ -125,7 +118,8 @@ const executionContext: KibanaExecutionContext = {
},
};
// Exported for testing purposes only.
const EXPRESSION_BUILD_ERROR_ID = 'expression_build_error';
export const WorkspacePanel = React.memo(function WorkspacePanel(props: WorkspacePanelProps) {
const { getSuggestionForField, ...restProps } = props;
@ -151,6 +145,8 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
ExpressionRenderer: ExpressionRendererComponent,
suggestionForDraggedField,
lensInspector,
getUserMessages,
addUserMessages,
}: Omit<WorkspacePanelProps, 'getSuggestionForField'> & {
suggestionForDraggedField: Suggestion | undefined;
}) {
@ -166,12 +162,10 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
const searchSessionId = useLensSelector(selectSearchSessionId);
const [localState, setLocalState] = useState<WorkspaceState>({
expressionBuildError: undefined,
expandError: false,
expressionToRender: undefined,
});
// const expressionToRender = useRef<null | undefined | string>();
const initialRenderComplete = useRef<boolean>();
const renderDeps = useRef<{
@ -228,14 +222,17 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
}
}, []);
const removeSearchWarningMessagesRef = useRef<() => void>();
const removeExpressionBuildErrorsRef = useRef<() => void>();
const onData$ = useCallback(
(data: unknown, adapters?: Partial<DefaultInspectorAdapters>) => {
(_data: unknown, adapters?: Partial<DefaultInspectorAdapters>) => {
if (renderDeps.current) {
const [defaultLayerId] = Object.keys(renderDeps.current.datasourceLayers);
const datasource = Object.values(renderDeps.current.datasourceMap)[0];
const datasourceState = Object.values(renderDeps.current.datasourceStates)[0].state;
let requestWarnings: Array<React.ReactNode | string> = [];
let requestWarnings: UserMessage[] = [];
if (adapters?.requests) {
requestWarnings = getSearchWarningMessages(
@ -248,24 +245,31 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
);
}
dispatchLens(
onActiveDataChange({
activeData:
adapters && adapters.tables
? Object.entries(adapters.tables?.tables).reduce<Record<string, Datatable>>(
(acc, [key, value], index, tables) => ({
...acc,
[tables.length === 1 ? defaultLayerId : key]: value,
}),
{}
)
: undefined,
requestWarnings,
})
);
if (requestWarnings.length) {
removeSearchWarningMessagesRef.current = addUserMessages(
requestWarnings.filter(isMessageRemovable)
);
} else if (removeSearchWarningMessagesRef.current) {
removeSearchWarningMessagesRef.current();
removeSearchWarningMessagesRef.current = undefined;
}
if (adapters && adapters.tables) {
dispatchLens(
onActiveDataChange({
activeData: Object.entries(adapters.tables?.tables).reduce<Record<string, Datatable>>(
(acc, [key, value], _index, tables) => ({
...acc,
[tables.length === 1 ? defaultLayerId : key]: value,
}),
{}
),
})
);
}
}
},
[dispatchLens, plugins.data.search]
[addUserMessages, dispatchLens, plugins.data.search]
);
const shouldApplyExpression = autoApplyEnabled || !initialRenderComplete.current || triggerApply;
@ -273,56 +277,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
? visualizationMap[visualization.activeId]
: null;
const missingIndexPatterns = getMissingIndexPattern(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId ? datasourceStates[activeDatasourceId] : null,
dataViews.indexPatterns
);
const missingRefsErrors = missingIndexPatterns.length
? [
{
shortMessage: '',
longMessage: i18n.translate('xpack.lens.indexPattern.missingDataView', {
defaultMessage:
'The {count, plural, one {data view} other {data views}} ({count, plural, one {id} other {ids}}: {indexpatterns}) cannot be found',
values: {
count: missingIndexPatterns.length,
indexpatterns: missingIndexPatterns.join(', '),
},
}),
},
]
: [];
const unknownVisError = visualization.activeId && !activeVisualization;
// Note: mind to all these eslint disable lines: the frameAPI will change too frequently
// and to prevent race conditions it is ok to leave them there.
const configurationValidationError = useMemo(
() =>
validateDatasourceAndVisualization(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId && datasourceStates[activeDatasourceId]?.state,
activeVisualization,
visualization.state,
framePublicAPI
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
activeVisualization,
visualization.state,
activeDatasourceId,
datasourceMap,
datasourceStates,
framePublicAPI.dateRange,
]
);
const workspaceErrors = getUserMessages(['visualization', 'visualizationInEditor'], {
severity: 'error',
});
// if the expression is undefined, it means we hit an error that should be displayed to the user
const unappliedExpression = useMemo(() => {
if (!configurationValidationError?.length && !missingRefsErrors.length && !unknownVisError) {
// shouldn't build expression if there is any type of error other than an expression build error
// (in which case we try again every time because the config might have changed)
if (workspaceErrors.every((error) => error.uniqueId === EXPRESSION_BUILD_ERROR_ID)) {
try {
const ast = buildExpression({
visualization: activeVisualization,
@ -344,39 +307,42 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
return null;
}
} catch (e) {
const buildMessages = activeVisualization?.getErrorMessages(visualization.state);
const defaultMessage = {
shortMessage: i18n.translate('xpack.lens.editorFrame.buildExpressionError', {
defaultMessage: 'An unexpected error occurred while preparing the chart',
}),
longMessage: e.toString(),
};
// Most likely an error in the expression provided by a datasource or visualization
setLocalState((s) => ({
...s,
expressionBuildError: buildMessages ?? [defaultMessage],
}));
removeExpressionBuildErrorsRef.current = addUserMessages([
{
uniqueId: EXPRESSION_BUILD_ERROR_ID,
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate('xpack.lens.editorFrame.buildExpressionError', {
defaultMessage: 'An unexpected error occurred while preparing the chart',
}),
longMessage: (
<>
<p data-test-subj="expression-failure">
<FormattedMessage
id="xpack.lens.editorFrame.expressionFailure"
defaultMessage="An error occurred in the expression"
/>
</p>
<p>{e.toString()}</p>
</>
),
},
]);
}
}
if (unknownVisError) {
setLocalState((s) => ({
...s,
expressionBuildError: [getUnknownVisualizationTypeError(visualization.activeId!)],
}));
}
}, [
configurationValidationError?.length,
missingRefsErrors.length,
unknownVisError,
workspaceErrors,
activeVisualization,
visualization.state,
visualization.activeId,
datasourceMap,
datasourceStates,
datasourceLayers,
dataViews.indexPatterns,
searchSessionId,
framePublicAPI.dateRange,
searchSessionId,
addUserMessages,
]);
useEffect(() => {
@ -399,6 +365,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
}, [unappliedExpression, shouldApplyExpression]);
const expressionExists = Boolean(localState.expressionToRender);
useEffect(() => {
// reset expression error if component attempts to run it again
if (expressionExists && removeExpressionBuildErrorsRef.current) {
removeExpressionBuildErrorsRef.current();
removeExpressionBuildErrorsRef.current = undefined;
}
}, [expressionExists]);
useEffect(() => {
// null signals an empty workspace which should count as an initial render
if (
@ -464,16 +439,6 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
[plugins.uiActions]
);
useEffect(() => {
// reset expression error if component attempts to run it again
if (expressionExists && localState.expressionBuildError) {
setLocalState((s) => ({
...s,
expressionBuildError: undefined,
}));
}
}, [expressionExists, localState.expressionBuildError]);
const onDrop = useCallback(() => {
if (suggestionForDraggedField) {
trackUiCounterEvents('drop_onto_workspace');
@ -588,7 +553,8 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
onEvent={onEvent}
hasCompatibleActions={hasCompatibleActions}
setLocalState={setLocalState}
localState={{ ...localState, configurationValidationError, missingRefsErrors }}
localState={{ ...localState }}
errors={workspaceErrors}
ExpressionRendererComponent={ExpressionRendererComponent}
core={core}
activeDatasourceId={activeDatasourceId}
@ -650,6 +616,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
visualizationMap={visualizationMap}
isFullscreen={isFullscreen}
lensInspector={lensInspector}
getUserMessages={getUserMessages}
>
{renderWorkspace()}
</WorkspacePanelWrapper>
@ -664,6 +631,7 @@ export const VisualizationWrapper = ({
hasCompatibleActions,
setLocalState,
localState,
errors,
ExpressionRendererComponent,
core,
activeDatasourceId,
@ -676,15 +644,8 @@ export const VisualizationWrapper = ({
onEvent: (event: ExpressionRendererEvent) => void;
hasCompatibleActions: (event: ExpressionRendererEvent) => Promise<boolean>;
setLocalState: (dispatch: (prevState: WorkspaceState) => WorkspaceState) => void;
localState: WorkspaceState & {
configurationValidationError?: Array<{
shortMessage: string;
longMessage: React.ReactNode;
fixAction?: DatasourceFixAction<unknown>;
}>;
missingRefsErrors?: Array<{ shortMessage: string; longMessage: React.ReactNode }>;
unknownVisError?: Array<{ shortMessage: string; longMessage: React.ReactNode }>;
};
localState: WorkspaceState;
errors: UserMessage[];
ExpressionRendererComponent: ReactExpressionRendererType;
core: CoreStart;
activeDatasourceId: string | null;
@ -706,171 +667,46 @@ export const VisualizationWrapper = ({
);
const searchSessionId = useLensSelector(selectSearchSessionId);
const dispatchLens = useLensDispatch();
if (errors?.length) {
const showExtraErrorsAction =
!localState.expandError && errors.length > 1 ? (
<EuiButtonEmpty
onClick={() => {
setLocalState((prevState: WorkspaceState) => ({
...prevState,
expandError: !prevState.expandError,
}));
}}
data-test-subj="workspace-more-errors-button"
>
{i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', {
defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`,
values: { errors: errors.length - 1 },
})}
</EuiButtonEmpty>
) : null;
function renderFixAction(
validationError:
| {
shortMessage: string;
longMessage: React.ReactNode;
fixAction?: DatasourceFixAction<unknown>;
}
| undefined
) {
return (
validationError &&
validationError.fixAction &&
activeDatasourceId && (
<>
<EuiButton
data-test-subj="errorFixAction"
onClick={async () => {
const newState = await validationError.fixAction?.newState({
...framePublicAPI,
...context,
});
dispatchLens(
updateDatasourceState({
updater: newState,
datasourceId: activeDatasourceId,
})
);
}}
>
{validationError.fixAction.label}
</EuiButton>
<EuiSpacer />
</>
)
);
}
if (localState.configurationValidationError?.length) {
let showExtraErrors = null;
let showExtraErrorsAction = null;
if (localState.configurationValidationError.length > 1) {
if (localState.expandError) {
showExtraErrors = localState.configurationValidationError
.slice(1)
.map((validationError) => (
<>
<p
key={validationError.shortMessage}
className="eui-textBreakWord"
data-test-subj="configuration-failure-error"
>
{validationError.longMessage}
</p>
{renderFixAction(validationError)}
</>
));
} else {
showExtraErrorsAction = (
<EuiButtonEmpty
onClick={() => {
setLocalState((prevState: WorkspaceState) => ({
...prevState,
expandError: !prevState.expandError,
}));
}}
data-test-subj="configuration-failure-more-errors"
>
{i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', {
defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`,
values: { errors: localState.configurationValidationError.length - 1 },
})}
</EuiButtonEmpty>
);
}
}
const [firstMessage, ...rest] = errors;
return (
<EuiFlexGroup data-test-subj="configuration-failure">
<EuiFlexGroup>
<EuiFlexItem>
<EuiEmptyPrompt
actions={showExtraErrorsAction}
body={
<>
<p className="eui-textBreakWord" data-test-subj="configuration-failure-error">
{localState.configurationValidationError[0].longMessage}
</p>
{renderFixAction(localState.configurationValidationError?.[0])}
{showExtraErrors}
</>
}
iconColor="danger"
iconType="alert"
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (localState.missingRefsErrors?.length) {
// Check for access to both Management app && specific indexPattern section
const { management: isManagementEnabled } = core.application.capabilities.navLinks;
const isIndexPatternManagementEnabled =
core.application.capabilities.management.kibana.indexPatterns;
return (
<EuiFlexGroup data-test-subj="configuration-failure">
<EuiFlexItem>
<EuiEmptyPrompt
actions={
isManagementEnabled && isIndexPatternManagementEnabled ? (
<RedirectAppLinks coreStart={core}>
<a
href={core.application.getUrlForApp('management', {
path: '/kibana/indexPatterns/create',
})}
style={{ width: '100%' }}
data-test-subj="configuration-failure-reconfigure-indexpatterns"
>
{i18n.translate('xpack.lens.editorFrame.dataViewReconfigure', {
defaultMessage: `Recreate it in the data view management page`,
})}
</a>
</RedirectAppLinks>
) : null
}
body={
<>
<p className="eui-textBreakWord" data-test-subj="missing-refs-failure">
<FormattedMessage
id="xpack.lens.editorFrame.dataViewNotFound"
defaultMessage="Data view not found"
/>
</p>
<p className="eui-textBreakWord lnsSelectableErrorMessage">
{localState.missingRefsErrors[0].longMessage}
</p>
</>
}
iconColor="danger"
iconType="alert"
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (localState.expressionBuildError?.length) {
const firstError = localState.expressionBuildError[0];
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiEmptyPrompt
body={
<>
<p data-test-subj="expression-failure">
<FormattedMessage
id="xpack.lens.editorFrame.expressionFailure"
defaultMessage="An error occurred in the expression"
/>
</p>
<p>{firstError.longMessage}</p>
<div data-test-subj="workspace-error-message">{firstMessage.longMessage}</div>
{localState.expandError && (
<>
<EuiSpacer />
{rest.map((message) => (
<div data-test-subj="workspace-error-message">
{message.longMessage}
<EuiSpacer />
</div>
))}
</>
)}
</>
}
iconColor="danger"

View file

@ -41,6 +41,7 @@ describe('workspace_panel_wrapper', () => {
datasourceStates={{}}
isFullscreen={false}
lensInspector={{} as unknown as LensInspector}
getUserMessages={() => []}
>
<MyChild />
</WorkspacePanelWrapper>
@ -63,6 +64,7 @@ describe('workspace_panel_wrapper', () => {
datasourceStates={{}}
isFullscreen={false}
lensInspector={{} as unknown as LensInspector}
getUserMessages={() => []}
/>
);
@ -118,6 +120,7 @@ describe('workspace_panel_wrapper', () => {
datasourceStates={{}}
isFullscreen={false}
lensInspector={{} as unknown as LensInspector}
getUserMessages={() => []}
>
<div />
</WorkspacePanelWrapper>

View file

@ -11,7 +11,12 @@ import React, { useCallback } from 'react';
import { EuiPageTemplate, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import classNames from 'classnames';
import { FormattedMessage } from '@kbn/i18n-react';
import { DatasourceMap, FramePublicAPI, VisualizationMap } from '../../../types';
import {
DatasourceMap,
FramePublicAPI,
UserMessagesGetter,
VisualizationMap,
} from '../../../types';
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../../../utils';
import { NativeRenderer } from '../../../native_renderer';
import { ChartSwitch } from './chart_switch';
@ -21,7 +26,6 @@ import {
updateVisualizationState,
DatasourceStates,
VisualizationState,
updateDatasourceState,
useLensSelector,
selectChangesApplied,
applyChanges,
@ -43,6 +47,7 @@ export interface WorkspacePanelWrapperProps {
datasourceStates: DatasourceStates;
isFullscreen: boolean;
lensInspector: LensInspector;
getUserMessages: UserMessagesGetter;
}
export function WorkspacePanelWrapper({
@ -52,9 +57,8 @@ export function WorkspacePanelWrapper({
visualizationId,
visualizationMap,
datasourceMap,
datasourceStates,
isFullscreen,
lensInspector,
getUserMessages,
}: WorkspacePanelWrapperProps) {
const dispatchLens = useLensDispatch();
@ -77,37 +81,13 @@ export function WorkspacePanelWrapper({
},
[dispatchLens, activeVisualization]
);
const setDatasourceState = useCallback(
(updater: unknown, datasourceId: string) => {
dispatchLens(
updateDatasourceState({
updater,
datasourceId,
})
);
},
[dispatchLens]
);
const warningMessages: React.ReactNode[] = [];
if (activeVisualization?.getWarningMessages) {
warningMessages.push(
...(activeVisualization.getWarningMessages(visualizationState, framePublicAPI) || [])
);
}
Object.entries(datasourceStates).forEach(([datasourceId, datasourceState]) => {
const datasource = datasourceMap[datasourceId];
if (!datasourceState.isLoading && datasource.getWarningMessages) {
warningMessages.push(
...(datasource.getWarningMessages(
datasourceState.state,
framePublicAPI,
lensInspector.adapters,
(updater) => setDatasourceState(updater, datasourceId)
) || [])
);
}
});
warningMessages.push(
...getUserMessages('toolbar', { severity: 'warning' }).map(({ longMessage }) => longMessage)
);
if (requestWarnings) {
warningMessages.push(...requestWarnings);
}

View file

@ -149,35 +149,6 @@ export function getOriginalRequestErrorMessages(error?: ExpressionRenderError |
return errorMessages;
}
export function getMissingVisualizationTypeError() {
return i18n.translate('xpack.lens.editorFrame.expressionMissingVisualizationType', {
defaultMessage: 'Visualization type not found.',
});
}
export function getMissingCurrentDatasource() {
return i18n.translate('xpack.lens.editorFrame.expressionMissingDatasource', {
defaultMessage: 'Could not find datasource for the visualization',
});
}
export function getMissingIndexPatterns(indexPatternIds: string[]) {
return i18n.translate('xpack.lens.editorFrame.expressionMissingDataView', {
defaultMessage: 'Could not find the {count, plural, one {data view} other {data views}}: {ids}',
values: { count: indexPatternIds.length, ids: indexPatternIds.join(', ') },
});
}
export function getUnknownVisualizationTypeError(visType: string) {
return {
shortMessage: i18n.translate('xpack.lens.unknownVisType.shortMessage', {
defaultMessage: `Unknown visualization type`,
}),
longMessage: i18n.translate('xpack.lens.unknownVisType.longMessage', {
defaultMessage: `The visualization type {visType} could not be resolved.`,
values: {
visType,
},
}),
};
}
// NOTE - if you are adding a new error message, add it as a UserMessage in get_application_error_messages
// or the getUserMessages method of a particular datasource or visualization class! Alternatively, use the
// addUserMessage function passed down by the application component.

View file

@ -118,7 +118,13 @@ export class EditorFrameService {
const { EditorFrame } = await import('../async_services');
return {
EditorFrameContainer: ({ showNoDataPopover, lensInspector, indexPatternService }) => {
EditorFrameContainer: ({
showNoDataPopover,
lensInspector,
indexPatternService,
getUserMessages,
addUserMessages,
}) => {
return (
<div className="lnsApp__frame">
<EditorFrame
@ -127,6 +133,8 @@ export class EditorFrameService {
plugins={plugins}
lensInspector={lensInspector}
showNoDataPopover={showNoDataPopover}
getUserMessages={getUserMessages}
addUserMessages={addUserMessages}
indexPatternService={indexPatternService}
datasourceMap={resolvedDatasources}
visualizationMap={resolvedVisualizations}

View file

@ -8,9 +8,3 @@
import type { Datatable } from '@kbn/expressions-plugin/common';
export type TableInspectorAdapter = Record<string, Datatable>;
export interface ErrorMessage {
shortMessage: string;
longMessage: React.ReactNode;
type?: 'fixable' | 'critical';
}

View file

@ -23,7 +23,7 @@ import { Document } from '../persistence';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public/embeddable';
import { coreMock, httpServiceMock, themeServiceMock } from '@kbn/core/public/mocks';
import { IBasePath, IUiSettingsClient } from '@kbn/core/public';
import { CoreStart, IBasePath, IUiSettingsClient } from '@kbn/core/public';
import { AttributeService, ViewMode } from '@kbn/embeddable-plugin/public';
import { LensAttributeService } from '../lens_attribute_service';
import { OnSaveProps } from '@kbn/saved-objects-plugin/public/save_modal';
@ -143,6 +143,7 @@ describe('embeddable', () => {
attributeService,
data: dataMock,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
dataViews: {} as DataViewsContract,
capabilities: {
@ -166,7 +167,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
},
@ -194,6 +196,7 @@ describe('embeddable', () => {
attributeService,
data: dataMock,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
dataViews: {} as DataViewsContract,
capabilities: {
@ -217,7 +220,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
},
@ -253,6 +257,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
dataViews: {} as DataViewsContract,
inspector: inspectorPluginMock.createStartContract(),
@ -276,7 +281,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{
@ -305,6 +311,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -328,11 +335,23 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: [{ shortMessage: '', longMessage: 'my validation error' }],
indexPatterns: {},
indexPatternRefs: [],
}),
},
{} as LensEmbeddableInput
);
jest.spyOn(embeddable, 'getUserMessages').mockReturnValue([
{
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
longMessage: 'lol',
shortMessage: 'lol',
},
]);
await embeddable.initializeSavedVis({} as LensEmbeddableInput);
embeddable.render(mountpoint);
@ -368,6 +387,7 @@ describe('embeddable', () => {
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
inspector: inspectorPluginMock.createStartContract(),
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
dataViews: {} as DataViewsContract,
spaces: spacesPluginStart,
@ -391,7 +411,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{} as LensEmbeddableInput
@ -418,6 +439,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {
@ -443,7 +465,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{} as LensEmbeddableInput
@ -469,6 +492,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {
@ -494,7 +518,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{} as LensEmbeddableInput
@ -518,6 +543,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -541,7 +567,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123' } as LensEmbeddableInput
@ -571,6 +598,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -594,7 +622,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123' } as LensEmbeddableInput
@ -628,6 +657,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -651,7 +681,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123' } as LensEmbeddableInput
@ -683,6 +714,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -706,7 +738,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123' } as LensEmbeddableInput
@ -745,6 +778,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -768,7 +802,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
input
@ -808,6 +843,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -831,7 +867,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
input
@ -874,6 +911,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: { get: jest.fn() } as unknown as DataViewsContract,
@ -897,7 +935,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
input
@ -925,6 +964,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -948,7 +988,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123' } as LensEmbeddableInput
@ -978,6 +1019,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -1001,7 +1043,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123' } as LensEmbeddableInput
@ -1028,6 +1071,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -1051,7 +1095,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123', timeRange, query, filters } as LensEmbeddableInput
@ -1094,6 +1139,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -1117,7 +1163,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123', onLoad } as unknown as LensEmbeddableInput
@ -1178,6 +1225,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -1201,7 +1249,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123', onFilter } as unknown as LensEmbeddableInput
@ -1237,6 +1286,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -1260,7 +1310,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123', onBrushEnd } as unknown as LensEmbeddableInput
@ -1293,6 +1344,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -1316,7 +1368,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
},
{ id: '123', onTableRowClick } as unknown as LensEmbeddableInput
@ -1347,7 +1400,8 @@ describe('embeddable', () => {
},
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
};
});
@ -1370,6 +1424,7 @@ describe('embeddable', () => {
data: dataMock,
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
inspector: inspectorPluginMock.createStartContract(),
dataViews: {} as DataViewsContract,
@ -1385,6 +1440,7 @@ describe('embeddable', () => {
visualizationMap: {
[visDocument.visualizationType as string]: {
onEditAction: onEditActionMock,
initialize: () => {},
} as unknown as Visualization,
},
datasourceMap: {},
@ -1438,6 +1494,7 @@ describe('embeddable', () => {
attributeService: attributeServiceMockFromSavedVis(visDocument),
data: dataMock,
expressionRenderer,
coreStart: {} as CoreStart,
basePath,
dataViews: {} as DataViewsContract,
capabilities: {
@ -1452,6 +1509,7 @@ describe('embeddable', () => {
visualizationMap: {
[visDocument.visualizationType as string]: {
getDisplayOptions: displayOptions ? () => displayOptions : undefined,
initialize: () => {},
} as unknown as Visualization,
},
datasourceMap: {},
@ -1465,7 +1523,8 @@ describe('embeddable', () => {
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
indexPatterns: {},
indexPatternRefs: [],
}),
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
},

View file

@ -60,6 +60,7 @@ import type { Action, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { DataViewsContract, DataView } from '@kbn/data-views-plugin/public';
import type {
Capabilities,
CoreStart,
IBasePath,
IUiSettingsClient,
KibanaExecutionContext,
@ -67,7 +68,7 @@ import type {
} from '@kbn/core/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { BrushTriggerEvent, ClickTriggerEvent, Warnings } from '@kbn/charts-plugin/public';
import { DataViewPersistableStateService, DataViewSpec } from '@kbn/data-views-plugin/common';
import { DataViewSpec } from '@kbn/data-views-plugin/common';
import { getExecutionContextEvents, trackUiCounterEvents } from '../lens_ui_telemetry';
import { Document } from '../persistence';
import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper';
@ -83,11 +84,17 @@ import {
Datasource,
IndexPatternMap,
GetCompatibleCellValueActions,
UserMessage,
IndexPatternRef,
FrameDatasourceAPI,
AddUserMessages,
isMessageRemovable,
UserMessagesGetter,
} from '../types';
import { getEditPath, DOC_TYPE } from '../../common';
import { LensAttributeService } from '../lens_attribute_service';
import type { ErrorMessage, TableInspectorAdapter } from '../editor_frame_service/types';
import type { TableInspectorAdapter } from '../editor_frame_service/types';
import { getLensInspectorService, LensInspector } from '../lens_inspector_service';
import { SharingSavedObjectProps, VisualizationDisplayOptions } from '../types';
import {
@ -98,7 +105,10 @@ import {
inferTimeField,
} from '../utils';
import { getLayerMetaInfo, combineQueryAndFilters } from '../app_plugin/show_underlying_data';
import { convertDataViewIntoLensIndexPattern } from '../data_views_service/loader';
import {
filterUserMessages,
getApplicationUserMessages,
} from '../app_plugin/get_application_user_messages';
export type LensSavedObjectAttributes = Omit<Document, 'savedObjectId' | 'type'>;
@ -141,9 +151,11 @@ export interface LensEmbeddableOutput extends EmbeddableOutput {
export interface LensEmbeddableDeps {
attributeService: LensAttributeService;
data: DataPublicPluginStart;
documentToExpression: (
doc: Document
) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>;
documentToExpression: (doc: Document) => Promise<{
ast: Ast | null;
indexPatterns: IndexPatternMap;
indexPatternRefs: IndexPatternRef[];
}>;
injectFilterReferences: FilterManager['inject'];
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
@ -160,6 +172,7 @@ export interface LensEmbeddableDeps {
navLinks: Capabilities['navLinks'];
discover: Capabilities['discover'];
};
coreStart: CoreStart;
usageCollection?: UsageCollectionSetup;
spaces?: SpacesPluginStart;
theme: ThemeServiceStart;
@ -178,11 +191,8 @@ const getExpressionFromDocument = async (
document: Document,
documentToExpression: LensEmbeddableDeps['documentToExpression']
) => {
const { ast, errors } = await documentToExpression(document);
return {
expression: ast ? toExpression(ast) : null,
errors,
};
const { ast, indexPatterns, indexPatternRefs } = await documentToExpression(document);
return { ast: ast ? toExpression(ast) : null, indexPatterns, indexPatternRefs };
};
function getViewUnderlyingDataArgs({
@ -276,7 +286,6 @@ export class Embeddable
private warningDomNode: HTMLElement | Element | undefined;
private subscription: Subscription;
private isInitialized = false;
private errors: ErrorMessage[] | undefined;
private inputReloadSubscriptions: Subscription[];
private isDestroyed?: boolean;
private embeddableTitle?: string;
@ -296,15 +305,9 @@ export class Embeddable
searchSessionId?: string;
} = {};
private activeDataInfo: {
activeData?: TableInspectorAdapter;
activeDatasource?: Datasource;
activeDatasourceState?: unknown;
activeVisualization?: Visualization;
activeVisualizationState?: unknown;
} = {};
private activeData?: TableInspectorAdapter;
private indexPatterns: DataView[] = [];
private dataViews: DataView[] = [];
private viewUnderlyingDataArgs?: ViewUnderlyingDataArgs;
@ -326,6 +329,7 @@ export class Embeddable
let containerStateChangedCalledAlready = false;
this.initializeSavedVis(initialInput)
.then(() => {
this.loadUserMessages();
if (!containerStateChangedCalledAlready) {
this.onContainerStateChanged(initialInput);
} else {
@ -417,6 +421,152 @@ export class Embeddable
);
}
private get activeDatasourceId() {
return getActiveDatasourceIdFromDoc(this.savedVis);
}
private get activeDatasource() {
if (!this.activeDatasourceId) return;
return this.deps.datasourceMap[this.activeDatasourceId];
}
private get activeVisualizationId() {
return getActiveVisualizationIdFromDoc(this.savedVis);
}
private get activeVisualization() {
if (!this.activeVisualizationId) return;
return this.deps.visualizationMap[this.activeVisualizationId];
}
private get activeVisualizationState() {
if (!this.activeVisualization) return;
return this.activeVisualization.initialize(() => '', this.savedVis?.state.visualization);
}
private indexPatterns: IndexPatternMap = {};
private indexPatternRefs: IndexPatternRef[] = [];
private get activeDatasourceState(): undefined | unknown {
if (!this.activeDatasourceId || !this.activeDatasource) return;
const docDatasourceState = this.savedVis?.state.datasourceStates[this.activeDatasourceId];
return this.activeDatasource.initialize(
docDatasourceState,
[...(this.savedVis?.references || []), ...(this.savedVis?.state.internalReferences || [])],
undefined,
undefined,
this.indexPatterns
);
}
public getUserMessages: UserMessagesGetter = (locationId, filters) => {
return filterUserMessages(
[...this._userMessages, ...Object.values(this.additionalUserMessages)],
locationId,
filters
);
};
private get hasAnyErrors() {
return this.getUserMessages(undefined, { severity: 'error' }).length > 0;
}
private _userMessages: UserMessage[] = [];
// loads all available user messages
private loadUserMessages() {
const userMessages: UserMessage[] = [];
if (this.activeVisualizationState && this.activeDatasource) {
userMessages.push(
...getApplicationUserMessages({
visualizationType: this.savedVis?.visualizationType,
visualization: {
state: this.activeVisualizationState,
activeId: this.activeVisualizationId,
},
visualizationMap: this.deps.visualizationMap,
activeDatasource: this.activeDatasource,
activeDatasourceState: { state: this.activeDatasourceState },
dataViews: {
indexPatterns: this.indexPatterns,
indexPatternRefs: this.indexPatternRefs, // TODO - are these actually used?
},
core: this.deps.coreStart,
})
);
}
const mergedSearchContext = this.getMergedSearchContext();
if (!this.savedVis) {
return userMessages;
}
const frameDatasourceAPI: FrameDatasourceAPI = {
dataViews: {
indexPatterns: this.indexPatterns,
indexPatternRefs: this.indexPatternRefs,
},
datasourceLayers: {}, // TODO
query: this.savedVis.state.query,
filters: mergedSearchContext.filters ?? [],
dateRange: {
fromDate: mergedSearchContext.timeRange?.from ?? '',
toDate: mergedSearchContext.timeRange?.to ?? '',
},
activeData: this.activeData,
};
userMessages.push(
...(this.activeDatasource?.getUserMessages(this.activeDatasourceState, {
setState: () => {},
frame: frameDatasourceAPI,
}) ?? []),
...(this.activeVisualization?.getUserMessages?.(this.activeVisualizationState, {
frame: frameDatasourceAPI,
}) ?? [])
);
this._userMessages = userMessages;
}
private additionalUserMessages: Record<string, UserMessage> = {};
// used to add warnings and errors from elsewhere in the embeddable
private addUserMessages: AddUserMessages = (messages) => {
const newMessageMap = {
...this.additionalUserMessages,
};
const addedMessageIds: string[] = [];
messages.forEach((message) => {
if (!newMessageMap[message.uniqueId]) {
addedMessageIds.push(message.uniqueId);
newMessageMap[message.uniqueId] = message;
}
});
if (addedMessageIds.length) {
this.additionalUserMessages = newMessageMap;
}
this.reload();
return () => {
const withMessagesRemoved = {
...this.additionalUserMessages,
};
messages.map(({ uniqueId }) => uniqueId).forEach((id) => delete withMessagesRemoved[id]);
this.additionalUserMessages = withMessagesRemoved;
};
};
public reportsEmbeddableLoad() {
return true;
}
@ -433,54 +583,6 @@ export class Embeddable
return this.lensInspector.adapters;
}
private maybeAddConflictError(
errors?: ErrorMessage[],
sharingSavedObjectProps?: SharingSavedObjectProps
) {
const ret = [...(errors || [])];
if (sharingSavedObjectProps?.outcome === 'conflict' && !!this.deps.spaces) {
ret.push({
shortMessage: i18n.translate('xpack.lens.embeddable.legacyURLConflict.shortMessage', {
defaultMessage: `You've encountered a URL conflict`,
}),
longMessage: (
<this.deps.spaces.ui.components.getEmbeddableLegacyUrlConflict
targetType={DOC_TYPE}
sourceId={sharingSavedObjectProps.sourceId!}
/>
),
});
}
return ret?.length ? ret : undefined;
}
private maybeAddTimeRangeError(
errors: ErrorMessage[] | undefined,
input: LensEmbeddableInput,
indexPatterns: DataView[]
) {
// if at least one indexPattern is time based, then the Lens embeddable requires the timeRange prop
if (
input.timeRange == null &&
indexPatterns.some((indexPattern) => indexPattern.isTimeBased())
) {
return [
...(errors || []),
{
shortMessage: i18n.translate('xpack.lens.embeddable.missingTimeRangeParam.shortMessage', {
defaultMessage: `Missing timeRange property`,
}),
longMessage: i18n.translate('xpack.lens.embeddable.missingTimeRangeParam.longMessage', {
defaultMessage: `The timeRange property is required for the given configuration`,
}),
},
];
}
return errors;
}
async initializeSavedVis(input: LensEmbeddableInput) {
const unwrapResult: LensUnwrapResult | false = await this.deps.attributeService
.unwrapAttributes(input)
@ -500,15 +602,40 @@ export class Embeddable
savedObjectId: (input as LensByReferenceInput)?.savedObjectId,
};
const { expression, errors } = await getExpressionFromDocument(
const { ast, indexPatterns, indexPatternRefs } = await getExpressionFromDocument(
this.savedVis,
this.deps.documentToExpression
);
this.expression = expression;
this.errors = this.maybeAddConflictError(errors, metaInfo?.sharingSavedObjectProps);
this.expression = ast;
this.indexPatterns = indexPatterns;
this.indexPatternRefs = indexPatternRefs;
if (metaInfo?.sharingSavedObjectProps?.outcome === 'conflict' && !!this.deps.spaces) {
this.addUserMessages([
{
uniqueId: 'url-conflict',
severity: 'error',
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate('xpack.lens.embeddable.legacyURLConflict.shortMessage', {
defaultMessage: `You've encountered a URL conflict`,
}),
longMessage: (
<this.deps.spaces.ui.components.getEmbeddableLegacyUrlConflict
targetType={DOC_TYPE}
sourceId={metaInfo?.sharingSavedObjectProps?.sourceId!}
/>
),
fixableInEditor: false,
},
]);
}
await this.initializeOutput();
// deferred loading of this embeddable is complete
this.setInitializationFinished();
this.isInitialized = true;
}
@ -551,37 +678,27 @@ export class Embeddable
return isDirty;
}
private handleWarnings(adapters?: Partial<DefaultInspectorAdapters>) {
const activeDatasourceId = getActiveDatasourceIdFromDoc(this.savedVis);
if (!activeDatasourceId || !adapters?.requests) {
return;
private getSearchWarningMessages(adapters?: Partial<DefaultInspectorAdapters>): UserMessage[] {
if (!this.activeDatasource || !this.activeDatasourceId || !adapters?.requests) {
return [];
}
const activeDatasource = this.deps.datasourceMap[activeDatasourceId];
const docDatasourceState = this.savedVis?.state.datasourceStates[activeDatasourceId];
const docDatasourceState = this.savedVis?.state.datasourceStates[this.activeDatasourceId];
const requestWarnings = getSearchWarningMessages(
adapters.requests,
activeDatasource,
this.activeDatasource,
docDatasourceState,
{
searchService: this.deps.data.search,
}
);
if (requestWarnings.length && this.warningDomNode) {
render(
<KibanaThemeProvider theme$={this.deps.theme.theme$}>
<Warnings warnings={requestWarnings} compressed />
</KibanaThemeProvider>,
this.warningDomNode
);
}
return requestWarnings;
}
private removeActiveDataWarningMessages: () => void = () => {};
private updateActiveData: ExpressionWrapperProps['onData$'] = (data, adapters) => {
this.activeDataInfo.activeData = adapters?.tables?.tables;
if (this.input.onLoad) {
// once onData$ is get's called from expression renderer, loading becomes false
this.input.onLoad(false, adapters);
@ -589,12 +706,23 @@ export class Embeddable
const { type, error } = data as { type: string; error: ErrorLike };
this.updateOutput({
...this.getOutput(),
loading: false,
error: type === 'error' ? error : undefined,
});
this.handleWarnings(adapters);
const newActiveData = adapters?.tables?.tables;
if (!fastIsEqual(this.activeData, newActiveData)) {
// we check equality because this.addUserMessage triggers a render, so we get an infinite loop
// if we just execute without checking if the data has changed
this.removeActiveDataWarningMessages();
const searchWarningMessages = this.getSearchWarningMessages(adapters);
this.removeActiveDataWarningMessages = this.addUserMessages(
searchWarningMessages.filter(isMessageRemovable)
);
}
this.activeData = newActiveData;
};
private onRender: ExpressionWrapperProps['onRender$'] = () => {
@ -662,25 +790,6 @@ export class Embeddable
}
}
private getError(): Error | undefined {
const message =
typeof this.errors?.[0]?.longMessage === 'string'
? this.errors[0].longMessage
: this.errors?.[0]?.shortMessage;
if (message != null) {
return new Error(message);
}
if (!this.expression) {
return new Error(
i18n.translate('xpack.lens.embeddable.failure', {
defaultMessage: "Visualization couldn't be displayed",
})
);
}
}
/**
*
* @param {HTMLElement} domNode
@ -698,15 +807,22 @@ export class Embeddable
this.domNode.setAttribute('data-shared-item', '');
const error = this.getError();
this.updateOutput({
...this.getOutput(),
loading: true,
error,
const errors = this.getUserMessages(['visualization', 'visualizationOnEmbeddable'], {
severity: 'error',
});
if (error) {
this.updateOutput({
loading: true,
error: errors.length
? new Error(
typeof errors[0].longMessage === 'string'
? errors[0].longMessage
: errors[0].shortMessage
)
: undefined,
});
if (errors.length) {
this.renderComplete.dispatchError();
} else {
this.renderComplete.dispatchInProgress();
@ -719,7 +835,7 @@ export class Embeddable
<ExpressionWrapper
ExpressionRenderer={this.expressionRenderer}
expression={this.expression || null}
errors={this.errors}
errors={errors}
lensInspector={this.lensInspector}
searchContext={this.getMergedSearchContext()}
variables={{
@ -763,6 +879,19 @@ export class Embeddable
</KibanaThemeProvider>,
domNode
);
const warningsToDisplay = this.getUserMessages('embeddableBadge', {
severity: 'warning',
});
if (warningsToDisplay.length && this.warningDomNode) {
render(
<KibanaThemeProvider theme$={this.deps.theme.theme$}>
<Warnings warnings={warningsToDisplay.map((message) => message.longMessage)} compressed />
</KibanaThemeProvider>,
this.warningDomNode
);
}
}
private readonly hasCompatibleActions = async (
@ -902,13 +1031,14 @@ export class Embeddable
newVis.state.visualization = this.onEditAction(newVis.state.visualization, event);
this.savedVis = newVis;
const { expression, errors } = await getExpressionFromDocument(
const { ast } = await getExpressionFromDocument(
this.savedVis,
this.deps.documentToExpression
);
this.expression = expression;
this.errors = errors;
this.expression = ast;
this.loadUserMessages();
this.reload();
}
};
@ -924,81 +1054,36 @@ export class Embeddable
}
private async loadViewUnderlyingDataArgs(): Promise<boolean> {
if (!this.savedVis || !this.activeDataInfo.activeData) {
if (
!this.savedVis ||
!this.activeData ||
!this.activeDatasource ||
!this.activeDatasourceState ||
!this.activeVisualization ||
!this.activeVisualizationState
) {
return false;
}
const mergedSearchContext = this.getMergedSearchContext();
if (!mergedSearchContext.timeRange) {
return false;
}
const activeDatasourceId = getActiveDatasourceIdFromDoc(this.savedVis);
if (!activeDatasourceId) {
return false;
}
const activeVisualizationId = getActiveVisualizationIdFromDoc(this.savedVis);
if (!activeVisualizationId) {
return false;
}
this.activeDataInfo.activeDatasource = this.deps.datasourceMap[activeDatasourceId];
this.activeDataInfo.activeVisualization = this.deps.visualizationMap[activeVisualizationId];
const docDatasourceState = this.savedVis?.state.datasourceStates[activeDatasourceId];
const adHocDataviews = await Promise.all(
Object.values(this.savedVis?.state.adHocDataViews || {})
.map((persistedSpec) => {
return DataViewPersistableStateService.inject(persistedSpec, [
...(this.savedVis?.references || []),
...(this.savedVis?.state.internalReferences || []),
]);
})
.map((spec) => this.deps.dataViews.create(spec))
);
const allIndexPatterns = [...this.indexPatterns, ...adHocDataviews];
const indexPatternsCache = allIndexPatterns.reduce(
(acc, indexPattern) => ({
[indexPattern.id!]: convertDataViewIntoLensIndexPattern(indexPattern),
...acc,
}),
{}
);
if (!this.activeDataInfo.activeDatasourceState) {
this.activeDataInfo.activeDatasourceState = this.activeDataInfo.activeDatasource.initialize(
docDatasourceState,
[...(this.savedVis?.references || []), ...(this.savedVis?.state.internalReferences || [])],
undefined,
undefined,
indexPatternsCache
);
}
if (!this.activeDataInfo.activeVisualizationState) {
this.activeDataInfo.activeVisualizationState =
this.activeDataInfo.activeVisualization.initialize(
() => '',
this.savedVis?.state.visualization
);
}
const viewUnderlyingDataArgs = getViewUnderlyingDataArgs({
activeDatasource: this.activeDataInfo.activeDatasource,
activeDatasourceState: this.activeDataInfo.activeDatasourceState,
activeVisualization: this.activeDataInfo.activeVisualization,
activeVisualizationState: this.activeDataInfo.activeVisualizationState,
activeData: this.activeDataInfo.activeData,
dataViews: this.indexPatterns,
activeDatasource: this.activeDatasource,
activeDatasourceState: this.activeDatasourceState,
activeVisualization: this.activeVisualization,
activeVisualizationState: this.activeVisualizationState,
activeData: this.activeData,
dataViews: this.dataViews,
capabilities: this.deps.capabilities,
query: mergedSearchContext.query,
filters: mergedSearchContext.filters || [],
timeRange: mergedSearchContext.timeRange,
esQueryConfig: getEsQueryConfig(this.deps.uiSettings),
indexPatternsCache,
indexPatternsCache: this.indexPatterns,
});
const loaded = typeof viewUnderlyingDataArgs !== 'undefined';
@ -1038,33 +1123,48 @@ export class Embeddable
)
).forEach((dataView) => indexPatterns.push(dataView));
this.indexPatterns = uniqBy(indexPatterns, 'id');
this.dataViews = uniqBy(indexPatterns, 'id');
// passing edit url and index patterns to the output of this embeddable for
// the container to pick them up and use them to configure filter bar and
// config dropdown correctly.
const input = this.getInput();
this.errors = this.maybeAddTimeRangeError(this.errors, input, this.indexPatterns);
// if at least one indexPattern is time based, then the Lens embeddable requires the timeRange prop
if (
input.timeRange == null &&
indexPatterns.some((indexPattern) => indexPattern.isTimeBased())
) {
this.addUserMessages([
{
uniqueId: 'missing-time-range-on-embeddable',
severity: 'error',
fixableInEditor: false,
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate('xpack.lens.embeddable.missingTimeRangeParam.shortMessage', {
defaultMessage: `Missing timeRange property`,
}),
longMessage: i18n.translate('xpack.lens.embeddable.missingTimeRangeParam.longMessage', {
defaultMessage: `The timeRange property is required for the given configuration`,
}),
},
]);
}
if (this.errors) {
if (this.hasAnyErrors) {
this.logError('validation');
}
const title = input.hidePanelTitles ? '' : input.title ?? this.savedVis.title;
const savedObjectId = (input as LensByReferenceInput).savedObjectId;
this.updateOutput({
...this.getOutput(),
defaultTitle: this.savedVis.title,
editable: this.getIsEditable(),
title,
editPath: getEditPath(savedObjectId),
editUrl: this.deps.basePath.prepend(`/app/lens${getEditPath(savedObjectId)}`),
indexPatterns: this.indexPatterns,
indexPatterns: this.dataViews,
});
// deferred loading of this embeddable is complete
this.setInitializationFinished();
}
private getIsEditable() {

View file

@ -7,6 +7,7 @@
import type {
Capabilities,
CoreStart,
HttpSetup,
IUiSettingsClient,
ThemeServiceStart,
@ -30,14 +31,14 @@ import type { LensByReferenceInput, LensEmbeddableInput } from './embeddable';
import type { Document } from '../persistence/saved_object_store';
import type { LensAttributeService } from '../lens_attribute_service';
import { DOC_TYPE } from '../../common/constants';
import type { ErrorMessage } from '../editor_frame_service/types';
import { extract, inject } from '../../common/embeddable_factory';
import type { DatasourceMap, VisualizationMap } from '../types';
import type { DatasourceMap, IndexPatternMap, IndexPatternRef, VisualizationMap } from '../types';
export interface LensEmbeddableStartServices {
data: DataPublicPluginStart;
timefilter: TimefilterContract;
coreHttp: HttpSetup;
coreStart: CoreStart;
inspector: InspectorStart;
attributeService: LensAttributeService;
capabilities: RecursiveReadonly<Capabilities>;
@ -45,9 +46,11 @@ export interface LensEmbeddableStartServices {
dataViews: DataViewsContract;
uiActions?: UiActionsStart;
usageCollection?: UsageCollectionSetup;
documentToExpression: (
doc: Document
) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>;
documentToExpression: (doc: Document) => Promise<{
ast: Ast | null;
indexPatterns: IndexPatternMap;
indexPatternRefs: IndexPatternRef[];
}>;
injectFilterReferences: FilterManager['inject'];
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
@ -106,6 +109,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
datasourceMap,
uiActions,
coreHttp,
coreStart,
attributeService,
dataViews,
capabilities,
@ -139,6 +143,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
navLinks: capabilities.navLinks,
discover: capabilities.discover,
},
coreStart,
usageCollection,
theme,
spaces,

View file

@ -19,13 +19,13 @@ import { ExecutionContextSearch } from '@kbn/data-plugin/public';
import { DefaultInspectorAdapters, RenderMode } from '@kbn/expressions-plugin/common';
import classNames from 'classnames';
import { getOriginalRequestErrorMessages } from '../editor_frame_service/error_helper';
import { ErrorMessage } from '../editor_frame_service/types';
import { LensInspector } from '../lens_inspector_service';
import { UserMessage } from '../types';
export interface ExpressionWrapperProps {
ExpressionRenderer: ReactExpressionRendererType;
expression: string | null;
errors: ErrorMessage[] | undefined;
errors: UserMessage[];
variables?: Record<string, unknown>;
interactive?: boolean;
searchContext: ExecutionContextSearch;
@ -57,8 +57,8 @@ interface VisualizationErrorProps {
}
export function VisualizationErrorPanel({ errors, canEdit }: VisualizationErrorProps) {
const showMore = errors && errors.length > 1;
const canFixInLens = canEdit && errors?.some(({ type }) => type === 'fixable');
const showMore = errors.length > 1;
const canFixInLens = canEdit && errors.some(({ fixableInEditor }) => fixableInEditor);
return (
<div className="lnsEmbeddedError">
<EuiEmptyPrompt
@ -67,7 +67,7 @@ export function VisualizationErrorPanel({ errors, canEdit }: VisualizationErrorP
data-test-subj="embeddable-lens-failure"
body={
<>
{errors ? (
{errors.length ? (
<>
<p>{errors[0].longMessage}</p>
{showMore && !canFixInLens ? (
@ -129,7 +129,7 @@ export function ExpressionWrapper({
}: ExpressionWrapperProps) {
return (
<I18nProvider>
{errors || expression === null || expression === '' ? (
{errors.length || expression === null || expression === '' ? (
<VisualizationErrorPanel errors={errors} canEdit={canEdit} />
) : (
<div className={classNames('lnsExpressionRenderer', className)} style={style}>

View file

@ -63,7 +63,7 @@ export function createMockDatasource(
// this is an additional property which doesn't exist on real datasources
// but can be used to validate whether specific API mock functions are called
publicAPIMock,
getErrorMessages: jest.fn((_state, _indexPatterns) => undefined),
getUserMessages: jest.fn((_state, _deps) => []),
checkIntegrity: jest.fn((_state, _indexPatterns) => []),
isTimeBased: jest.fn(),
isValidColumn: jest.fn(),

View file

@ -49,7 +49,6 @@ export function createMockVisualization(id = 'testVis'): jest.Mocked<Visualizati
setDimension: jest.fn(),
removeDimension: jest.fn(),
getErrorMessages: jest.fn((_state) => undefined),
renderDimensionEditor: jest.fn(),
};
}

View file

@ -300,6 +300,7 @@ export class LensPlugin {
attributeService: getLensAttributeService(coreStart, plugins),
capabilities: coreStart.application.capabilities,
coreHttp: coreStart.http,
coreStart,
data: plugins.data,
timefilter: plugins.data.query.timefilter.timefilter,
expressionRenderer: plugins.expressions.ReactExpressionRenderer,

View file

@ -31,7 +31,7 @@ export const DimensionTrigger = ({
id: string;
isInvalid?: boolean;
hideTooltip?: boolean;
invalidMessage?: string | JSX.Element;
invalidMessage?: string | React.ReactNode;
}) => {
if (isInvalid) {
return (

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import type { ReactNode } from 'react';
import { createAction, createReducer, current, PayloadAction } from '@reduxjs/toolkit';
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { mapValues, uniq } from 'lodash';
@ -125,8 +124,7 @@ export const getPreloadedState = ({
export const setState = createAction<Partial<LensAppState>>('lens/setState');
export const onActiveDataChange = createAction<{
activeData?: TableInspectorAdapter;
requestWarnings?: Array<ReactNode | string>;
activeData: TableInspectorAdapter;
}>('lens/onActiveDataChange');
export const setSaveable = createAction<boolean>('lens/setSaveable');
export const enableAutoApply = createAction<void>('lens/enableAutoApply');
@ -286,14 +284,11 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
},
[onActiveDataChange.type]: (
state,
{
payload: { activeData, requestWarnings },
}: PayloadAction<{ activeData: TableInspectorAdapter; requestWarnings?: string[] }>
{ payload: { activeData } }: PayloadAction<{ activeData: TableInspectorAdapter }>
) => {
return {
...state,
...(activeData ? { activeData } : {}),
...(requestWarnings ? { requestWarnings } : {}),
activeData,
};
},
[setSaveable.type]: (state, { payload }: PayloadAction<boolean>) => {

View file

@ -238,3 +238,8 @@ export const selectFramePublicAPI = createSelector(
};
}
);
export const selectFrameDatasourceAPI = createSelector(
[selectFramePublicAPI, selectExecutionContext],
(framePublicAPI, context) => ({ ...context, ...framePublicAPI })
);

View file

@ -18,7 +18,6 @@ import type {
ExpressionRendererEvent,
} from '@kbn/expressions-plugin/public';
import type { Configuration, NavigateToLensContext } from '@kbn/visualizations-plugin/common';
import { Adapters } from '@kbn/inspector-plugin/public';
import type { Query } from '@kbn/es-query';
import type {
UiActionsStart,
@ -105,6 +104,8 @@ export interface EditorFrameProps {
showNoDataPopover: () => void;
lensInspector: LensInspector;
indexPatternService: IndexPatternServiceAPI;
getUserMessages: UserMessagesGetter;
addUserMessages: AddUserMessages;
}
export type VisualizationMap = Record<string, Visualization>;
@ -272,6 +273,49 @@ interface DimensionLink {
};
}
type UserMessageDisplayLocation =
| {
// NOTE: We want to move toward more errors that do not block the render!
id:
| 'toolbar'
| 'embeddableBadge'
| 'visualization' // blocks render
| 'visualizationOnEmbeddable' // blocks render in embeddable only
| 'visualizationInEditor' // blocks render in editor only
| 'textBasedLanguagesQueryInput'
| 'banner';
}
| { id: 'dimensionTrigger'; dimensionId: string };
export type UserMessagesDisplayLocationId = UserMessageDisplayLocation['id'];
export interface UserMessage {
uniqueId?: string;
severity: 'error' | 'warning';
shortMessage: string;
longMessage: React.ReactNode | string;
fixableInEditor: boolean;
displayLocations: UserMessageDisplayLocation[];
}
export type RemovableUserMessage = UserMessage & { uniqueId: string };
export interface UserMessageFilters {
severity?: UserMessage['severity'];
dimensionId?: string;
}
export type UserMessagesGetter = (
locationId: UserMessagesDisplayLocationId | UserMessagesDisplayLocationId[] | undefined,
filters: UserMessageFilters
) => UserMessage[];
export type AddUserMessages = (messages: RemovableUserMessage[]) => () => void;
export function isMessageRemovable(message: UserMessage): message is RemovableUserMessage {
return Boolean(message.uniqueId);
}
/**
* Interface for the datasource registry
*/
@ -291,7 +335,6 @@ export interface Datasource<T = unknown, P = unknown> {
// Given the current state, which parts should be saved?
getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] };
getUnifiedSearchErrors?: (state: T) => Error[];
insertLayer: (state: T, newLayerId: string, linkToLayers?: string[]) => T;
createEmptyLayer: (indexPatternId: string) => T;
@ -433,28 +476,16 @@ export interface Datasource<T = unknown, P = unknown> {
*/
checkIntegrity: (state: T, indexPatterns: IndexPatternMap) => string[];
getErrorMessages: (
state: T,
indexPatterns: Record<string, IndexPattern>
) =>
| Array<{
shortMessage: string;
longMessage: React.ReactNode;
fixAction?: { label: string; newState: () => Promise<T> };
}>
| undefined;
/**
* The frame calls this function to display warnings about visualization
* The frame calls this function to display messages to the user
*/
getWarningMessages?: (
getUserMessages: (
state: T,
frame: FramePublicAPI,
adapters: Adapters,
setState: StateSetter<T>
) => React.ReactNode[] | undefined;
getDeprecationMessages?: (state: T) => React.ReactNode[] | undefined;
deps: {
frame: FrameDatasourceAPI;
setState: StateSetter<T>;
}
) => UserMessage[];
/**
* The embeddable calls this function to display warnings about visualization on the dashboard
@ -464,7 +495,7 @@ export interface Datasource<T = unknown, P = unknown> {
warning: SearchResponseWarning,
request: SearchRequest,
response: estypes.SearchResponse
) => Array<string | React.ReactNode> | undefined;
) => UserMessage[];
/**
* Checks if the visualization created is time based, for example date histogram
@ -623,7 +654,7 @@ export type DatasourceDimensionProps<T> = SharedDimensionProps & {
indexPatterns: IndexPatternMap;
hideTooltip?: boolean;
invalid?: boolean;
invalidMessage?: string;
invalidMessage?: string | React.ReactNode;
};
export type ParamEditorCustomProps = Record<string, unknown> & {
labels?: string[];
@ -835,9 +866,6 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
// this dimension group in the hierarchy. If not specified, the position of the dimension in the array is used. specified nesting
// orders are always higher in the hierarchy than non-specified ones.
nestingOrder?: number;
// some type of layers can produce groups even if invalid. Keep this information to visually show the user that.
invalid?: boolean;
invalidMessage?: string;
// need a special flag to know when to pass the previous column on duplicating
requiresPreviousColumnOnDuplicate?: boolean;
supportStaticValue?: boolean;
@ -1214,7 +1242,7 @@ export interface Visualization<T = unknown, P = unknown> {
label: string;
hideTooltip?: boolean;
invalid?: boolean;
invalidMessage?: string;
invalidMessage?: string | React.ReactNode;
}) => JSX.Element | null;
/**
* Creates map of columns ids and unique lables. Used only for noDatasource layers
@ -1246,32 +1274,12 @@ export interface Visualization<T = unknown, P = unknown> {
datasourceLayers: DatasourceLayers,
datasourceExpressionsByLayers?: Record<string, Ast>
) => ExpressionAstExpression | string | null;
/**
* The frame will call this function on all visualizations at few stages (pre-build/build error) in order
* to provide more context to the error and show it to the user
*/
getErrorMessages: (
state: T,
frame?: Pick<FramePublicAPI, 'datasourceLayers' | 'dataViews'>
) =>
| Array<{
shortMessage: string;
longMessage: React.ReactNode;
}>
| undefined;
validateColumn?: (
state: T,
frame: Pick<FramePublicAPI, 'dataViews'>,
layerId: string,
columnId: string,
group?: VisualizationDimensionGroupConfig
) => { invalid: boolean; invalidMessage?: string };
/**
* The frame calls this function to display warnings about visualization
*/
getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined;
getUserMessages?: (state: T, deps: { frame: FramePublicAPI }) => UserMessage[];
/**
* On Edit events the frame will call this to know what's going to be the next visualization state

View file

@ -16,7 +16,6 @@ import type { DatatableUtilitiesService } from '@kbn/data-plugin/common';
import { BrushTriggerEvent, ClickTriggerEvent } from '@kbn/charts-plugin/public';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { ISearchStart } from '@kbn/data-plugin/public';
import React from 'react';
import type { Document } from './persistence/saved_object_store';
import {
Datasource,
@ -27,6 +26,7 @@ import {
DraggedField,
DragDropOperation,
isOperation,
UserMessage,
} from './types';
import type { DatasourceStates, VisualizationState } from './state_management';
import type { IndexPatternServiceAPI } from './data_views_service/service';
@ -329,8 +329,8 @@ export const getSearchWarningMessages = (
deps: {
searchService: ISearchStart;
}
) => {
const warningsMap: Map<string, Array<string | React.ReactNode>> = new Map();
): UserMessage[] => {
const warningsMap: Map<string, UserMessage[]> = new Map();
deps.searchService.showWarnings(adapter, (warning, meta) => {
const { request, response, requestId } = meta;

View file

@ -25,8 +25,4 @@
align-items: center;
justify-content: center;
overflow: auto;
}
.lnsSelectableErrorMessage {
user-select: text;
}

View file

@ -695,60 +695,6 @@ describe('Datatable Visualization', () => {
});
});
describe('#getErrorMessages', () => {
it('returns undefined if the datasource is missing a metric dimension', () => {
const datasource = createMockDatasource('test');
const frame = mockFrame();
frame.datasourceLayers = { a: datasource.publicAPIMock };
datasource.publicAPIMock.getTableSpec.mockReturnValue([
{ columnId: 'c', fields: [] },
{ columnId: 'b', fields: [] },
]);
datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType: 'string',
isBucketed: true, // move it from the metric to the break down by side
label: 'label',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
});
const error = datatableVisualization.getErrorMessages({
layerId: 'a',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'b' }, { columnId: 'c' }],
});
expect(error).toBeUndefined();
});
it('returns undefined if the metric dimension is defined', () => {
const datasource = createMockDatasource('test');
const frame = mockFrame();
frame.datasourceLayers = { a: datasource.publicAPIMock };
datasource.publicAPIMock.getTableSpec.mockReturnValue([
{ columnId: 'c', fields: [] },
{ columnId: 'b', fields: [] },
]);
datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType: 'string',
isBucketed: false, // keep it a metric
label: 'label',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
});
const error = datatableVisualization.getErrorMessages({
layerId: 'a',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'b' }, { columnId: 'c' }],
});
expect(error).toBeUndefined();
});
});
describe('#onEditAction', () => {
it('should add a sort column to the state', () => {
const currentState: DatatableVisualizationState = {

View file

@ -499,10 +499,6 @@ export const getDatatableVisualization = ({
};
},
getErrorMessages(state) {
return undefined;
},
getRenderEventCounters(state) {
const events = {
color_by_value: false,

View file

@ -345,107 +345,6 @@ describe('gauge', () => {
],
});
});
test('resolves configuration when with group error when max < minimum', () => {
const state: GaugeVisualizationState = {
...exampleState(),
layerId: 'first',
metricAccessor: 'metric-accessor',
minAccessor: 'min-accessor',
maxAccessor: 'max-accessor',
goalAccessor: 'goal-accessor',
};
frame.activeData = {
first: {
type: 'datatable',
columns: [],
rows: [{ 'min-accessor': 10, 'max-accessor': 0 }],
},
};
expect(
getGaugeVisualization({
paletteService,
theme,
}).getConfiguration({ state, frame, layerId: 'first' })
).toEqual({
groups: [
{
layerId: 'first',
paramEditorCustomProps: {
headingLabel: 'Value',
},
groupId: GROUP_ID.METRIC,
groupLabel: 'Metric',
isMetricDimension: true,
accessors: [{ columnId: 'metric-accessor', triggerIconType: 'none' }],
filterOperations: isNumericDynamicMetric,
supportsMoreColumns: false,
requiredMinDimensionCount: 1,
dataTestSubj: 'lnsGauge_metricDimensionPanel',
enableDimensionEditor: true,
enableFormatSelector: true,
},
{
layerId: 'first',
paramEditorCustomProps: {
headingLabel: 'Value',
labels: ['Minimum value'],
},
groupId: GROUP_ID.MIN,
groupLabel: 'Minimum value',
isMetricDimension: true,
accessors: [{ columnId: 'min-accessor' }],
filterOperations: isNumericMetric,
supportsMoreColumns: false,
dataTestSubj: 'lnsGauge_minDimensionPanel',
prioritizedOperation: 'min',
suggestedValue: expect.any(Function),
enableFormatSelector: false,
supportStaticValue: true,
invalid: true,
invalidMessage: 'Minimum value may not be greater than maximum value',
},
{
layerId: 'first',
paramEditorCustomProps: {
headingLabel: 'Value',
labels: ['Maximum value'],
},
groupId: GROUP_ID.MAX,
groupLabel: 'Maximum value',
isMetricDimension: true,
accessors: [{ columnId: 'max-accessor' }],
filterOperations: isNumericMetric,
supportsMoreColumns: false,
dataTestSubj: 'lnsGauge_maxDimensionPanel',
prioritizedOperation: 'max',
suggestedValue: expect.any(Function),
enableFormatSelector: false,
supportStaticValue: true,
invalid: true,
invalidMessage: 'Minimum value may not be greater than maximum value',
},
{
layerId: 'first',
paramEditorCustomProps: {
headingLabel: 'Value',
labels: ['Goal value'],
},
groupId: GROUP_ID.GOAL,
groupLabel: 'Goal value',
isMetricDimension: true,
accessors: [{ columnId: 'goal-accessor' }],
filterOperations: isNumericMetric,
supportsMoreColumns: false,
requiredMinDimensionCount: 0,
dataTestSubj: 'lnsGauge_goalDimensionPanel',
enableFormatSelector: false,
supportStaticValue: true,
},
],
});
});
});
describe('#setDimension', () => {
@ -607,17 +506,7 @@ describe('gauge', () => {
});
});
describe('#getErrorMessages', () => {
it('returns undefined if no error is raised', () => {
const error = getGaugeVisualization({
paletteService,
theme,
}).getErrorMessages(exampleState());
expect(error).not.toBeDefined();
});
});
describe('#getWarningMessages', () => {
describe('#getUserMessages', () => {
beforeEach(() => {
const mockDatasource = createMockDatasource('testDatasource');
mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
@ -636,6 +525,51 @@ describe('gauge', () => {
maxAccessor: 'max-accessor',
goalAccessor: 'goal-accessor',
};
it('should report error when max < minimum', () => {
const localState: GaugeVisualizationState = {
...exampleState(),
layerId: 'first',
metricAccessor: 'metric-accessor',
minAccessor: 'min-accessor',
maxAccessor: 'max-accessor',
goalAccessor: 'goal-accessor',
};
frame.activeData = {
first: {
type: 'datatable',
columns: [],
rows: [{ 'min-accessor': 10, 'max-accessor': 0 }],
},
};
expect(
getGaugeVisualization({
paletteService,
theme,
}).getUserMessages!(localState, { frame })
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"dimensionId": "min-accessor",
"id": "dimensionTrigger",
},
Object {
"dimensionId": "max-accessor",
"id": "dimensionTrigger",
},
],
"fixableInEditor": true,
"longMessage": "",
"severity": "error",
"shortMessage": "Minimum value may not be greater than maximum value",
},
]
`);
});
it('should not warn for data in bounds', () => {
frame.activeData = {
first: {
@ -656,7 +590,7 @@ describe('gauge', () => {
getGaugeVisualization({
paletteService,
theme,
}).getWarningMessages!(state, frame)
}).getUserMessages!(state, { frame })
).toHaveLength(0);
});
it('should warn when minimum value is greater than metric value', () => {
@ -679,7 +613,7 @@ describe('gauge', () => {
getGaugeVisualization({
paletteService,
theme,
}).getWarningMessages!(state, frame)
}).getUserMessages!(state, { frame })
).toHaveLength(1);
});
@ -702,7 +636,7 @@ describe('gauge', () => {
getGaugeVisualization({
paletteService,
theme,
}).getWarningMessages!(state, frame)
}).getUserMessages!(state, { frame })
).toHaveLength(1);
});
it('should warn when goal value is greater than maximum value', () => {
@ -725,7 +659,7 @@ describe('gauge', () => {
getGaugeVisualization({
paletteService,
theme,
}).getWarningMessages!(state, frame)
}).getUserMessages!(state, { frame })
).toHaveLength(1);
});
it('should warn when minimum value is greater than goal value', () => {
@ -748,7 +682,7 @@ describe('gauge', () => {
getGaugeVisualization({
paletteService,
theme,
}).getWarningMessages!(state, frame)
}).getUserMessages!(state, { frame })
).toHaveLength(1);
});
});

View file

@ -25,7 +25,13 @@ import {
import { IconChartHorizontalBullet, IconChartVerticalBullet } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { FormBasedPersistedState } from '../../datasources/form_based/types';
import type { DatasourceLayers, OperationMetadata, Suggestion, Visualization } from '../../types';
import type {
DatasourceLayers,
OperationMetadata,
Suggestion,
UserMessage,
Visualization,
} from '../../types';
import { getSuggestions } from './suggestions';
import { GROUP_ID, LENS_GAUGE_ID, GaugeVisualizationState } from './constants';
import { GaugeToolbar } from './toolbar_component';
@ -76,35 +82,52 @@ function computePaletteParams(params: CustomPaletteParams) {
};
}
const checkInvalidConfiguration = (row?: DatatableRow, state?: GaugeVisualizationState) => {
const getErrorMessages = (row?: DatatableRow, state?: GaugeVisualizationState): UserMessage[] => {
if (!row || !state) {
return;
return [];
}
const errors: UserMessage[] = [];
const minAccessor = state?.minAccessor;
const maxAccessor = state?.maxAccessor;
const minValue = minAccessor ? getValueFromAccessor(minAccessor, row) : undefined;
const maxValue = maxAccessor ? getValueFromAccessor(maxAccessor, row) : undefined;
if (maxValue !== null && maxValue !== undefined && minValue != null && minValue !== undefined) {
if (maxValue < minValue) {
return {
invalid: true,
invalidMessage: i18n.translate(
errors.push({
severity: 'error',
displayLocations: [
{ id: 'dimensionTrigger', dimensionId: minAccessor! },
{ id: 'dimensionTrigger', dimensionId: maxAccessor! },
],
fixableInEditor: true,
shortMessage: i18n.translate(
'xpack.lens.guageVisualization.chartCannotRenderMinGreaterMax',
{
defaultMessage: 'Minimum value may not be greater than maximum value',
}
),
};
longMessage: '',
});
}
if (maxValue === minValue) {
return {
invalid: true,
invalidMessage: i18n.translate('xpack.lens.guageVisualization.chartCannotRenderEqual', {
errors.push({
severity: 'error',
displayLocations: [
{ id: 'dimensionTrigger', dimensionId: minAccessor! },
{ id: 'dimensionTrigger', dimensionId: maxAccessor! },
],
fixableInEditor: true,
shortMessage: i18n.translate('xpack.lens.guageVisualization.chartCannotRenderEqual', {
defaultMessage: 'Minimum and maximum values may not be equal',
}),
};
longMessage: '',
});
}
}
return errors;
};
const toExpression = (
@ -228,7 +251,6 @@ export const getGaugeVisualization = ({
const displayStops = applyPaletteParams(paletteService, state?.palette, currentMinMax);
palette = displayStops.map(({ color }) => color);
}
const invalidProps = checkInvalidConfiguration(row, state) || {};
return {
groups: [
@ -290,7 +312,6 @@ export const getGaugeVisualization = ({
dataTestSubj: 'lnsGauge_minDimensionPanel',
prioritizedOperation: 'min',
suggestedValue: () => (state.metricAccessor ? getMinValue(row, accessors) : undefined),
...invalidProps,
},
{
supportStaticValue: true,
@ -317,7 +338,6 @@ export const getGaugeVisualization = ({
dataTestSubj: 'lnsGauge_maxDimensionPanel',
prioritizedOperation: 'max',
suggestedValue: () => (state.metricAccessor ? getMaxValue(row, accessors) : undefined),
...invalidProps,
},
{
supportStaticValue: true,
@ -466,67 +486,92 @@ export const getGaugeVisualization = ({
toPreviewExpression: (state, datasourceLayers, datasourceExpressionsByLayers = {}) =>
toExpression(paletteService, state, datasourceLayers, undefined, datasourceExpressionsByLayers),
getErrorMessages(state) {
// not possible to break it?
return undefined;
},
getWarningMessages(state, frame) {
getUserMessages(state, { frame }) {
const { maxAccessor, minAccessor, goalAccessor, metricAccessor } = state;
if (!maxAccessor && !minAccessor && !goalAccessor && !metricAccessor) {
// nothing configured yet
return;
return [];
}
if (!metricAccessor) {
return [];
}
const row = frame?.activeData?.[state.layerId]?.rows?.[0];
if (!row || checkInvalidConfiguration(row, state)) {
const row = frame.activeData?.[state.layerId]?.rows?.[0];
if (!row) {
return [];
}
const errors = getErrorMessages(row, state);
if (errors.length) {
return errors;
}
const metricValue = row[metricAccessor];
const maxValue = maxAccessor && row[maxAccessor];
const minValue = minAccessor && row[minAccessor];
const goalValue = goalAccessor && row[goalAccessor];
const warnings = [];
const warnings: UserMessage[] = [];
if (typeof minValue === 'number') {
if (minValue > metricValue) {
warnings.push([
<FormattedMessage
id="xpack.lens.gaugeVisualization.minValueGreaterMetricShortMessage"
defaultMessage="Minimum value is greater than metric value."
/>,
]);
warnings.push({
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
id="xpack.lens.gaugeVisualization.minValueGreaterMetricShortMessage"
defaultMessage="Minimum value is greater than metric value."
/>
),
});
}
if (minValue > goalValue) {
warnings.push([
<FormattedMessage
id="xpack.lens.gaugeVisualization.minimumValueGreaterGoalShortMessage"
defaultMessage="Minimum value is greater than goal value."
/>,
]);
warnings.push({
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
id="xpack.lens.gaugeVisualization.minimumValueGreaterGoalShortMessage"
defaultMessage="Minimum value is greater than goal value."
/>
),
});
}
}
if (typeof maxValue === 'number') {
if (metricValue > maxValue) {
warnings.push([
<FormattedMessage
id="xpack.lens.gaugeVisualization.metricValueGreaterMaximumShortMessage"
defaultMessage="Metric value is greater than maximum value."
/>,
]);
warnings.push({
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
id="xpack.lens.gaugeVisualization.metricValueGreaterMaximumShortMessage"
defaultMessage="Metric value is greater than maximum value."
/>
),
});
}
if (typeof goalValue === 'number' && goalValue > maxValue) {
warnings.push([
<FormattedMessage
id="xpack.lens.gaugeVisualization.goalValueGreaterMaximumShortMessage"
defaultMessage="Goal value is greater than maximum value."
/>,
]);
warnings.push({
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
id="xpack.lens.gaugeVisualization.goalValueGreaterMaximumShortMessage"
defaultMessage="Goal value is greater than maximum value."
/>
),
});
}
}

View file

@ -21,7 +21,12 @@ import {
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { Position } from '@elastic/charts';
import type { HeatmapVisualizationState } from './types';
import type { DatasourceLayers, OperationDescriptor } from '../../types';
import type {
DatasourceLayers,
FramePublicAPI,
OperationDescriptor,
UserMessage,
} from '../../types';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { themeServiceMock } from '@kbn/core/public/mocks';
@ -581,7 +586,7 @@ describe('heatmap', () => {
});
});
describe('#getErrorMessages', () => {
describe('#getUserMessages', () => {
test('should not return an error when chart has empty configuration', () => {
const mockState = {
shape: CHART_SHAPES.HEATMAP,
@ -590,8 +595,10 @@ describe('heatmap', () => {
getHeatmapVisualization({
paletteService,
theme,
}).getErrorMessages(mockState)
).toEqual(undefined);
}).getUserMessages!(mockState, {
frame: {} as FramePublicAPI,
})
).toHaveLength(0);
});
test('should return an error when the X accessor is missing', () => {
@ -603,88 +610,108 @@ describe('heatmap', () => {
getHeatmapVisualization({
paletteService,
theme,
}).getErrorMessages(mockState)
).toEqual([
{
longMessage: 'Configuration for the horizontal axis is missing.',
shortMessage: 'Missing Horizontal axis.',
},
]);
});
});
describe('#getWarningMessages', () => {
beforeEach(() => {
const mockDatasource = createMockDatasource('testDatasource');
mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType: 'string',
label: 'MyOperation',
} as OperationDescriptor);
frame.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
}).getUserMessages!(mockState, {
frame: {} as FramePublicAPI,
})
).toMatchInlineSnapshot(`
Array [
Object {
"displayLocations": Array [
Object {
"id": "visualization",
},
],
"fixableInEditor": true,
"longMessage": "Configuration for the horizontal axis is missing.",
"severity": "error",
"shortMessage": "Missing Horizontal axis.",
},
]
`);
});
test('should not return warning messages when the layer it not configured', () => {
const mockState = {
shape: CHART_SHAPES.HEATMAP,
valueAccessor: 'v-accessor',
} as HeatmapVisualizationState;
expect(
getHeatmapVisualization({
paletteService,
theme,
}).getWarningMessages!(mockState, frame)
).toEqual(undefined);
});
describe('warnings', () => {
beforeEach(() => {
const mockDatasource = createMockDatasource('testDatasource');
test('should not return warning messages when the data table is empty', () => {
frame.activeData = {
first: {
type: 'datatable',
rows: [],
columns: [],
},
};
const mockState = {
shape: CHART_SHAPES.HEATMAP,
valueAccessor: 'v-accessor',
layerId: 'first',
} as HeatmapVisualizationState;
expect(
getHeatmapVisualization({
paletteService,
theme,
}).getWarningMessages!(mockState, frame)
).toEqual(undefined);
});
mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType: 'string',
label: 'MyOperation',
} as OperationDescriptor);
test('should return a warning message when cell value data contains arrays', () => {
frame.activeData = {
first: {
type: 'datatable',
rows: [
{
'v-accessor': [1, 2, 3],
},
],
columns: [],
},
};
frame.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
});
const mockState = {
shape: CHART_SHAPES.HEATMAP,
valueAccessor: 'v-accessor',
layerId: 'first',
} as HeatmapVisualizationState;
expect(
getHeatmapVisualization({
paletteService,
theme,
}).getWarningMessages!(mockState, frame)
).toHaveLength(1);
const onlyWarnings = (messages: UserMessage[]) =>
messages.filter(({ severity }) => severity === 'warning');
test('should not return warning messages when the layer it not configured', () => {
const mockState = {
shape: CHART_SHAPES.HEATMAP,
valueAccessor: 'v-accessor',
} as HeatmapVisualizationState;
expect(
onlyWarnings(
getHeatmapVisualization({
paletteService,
theme,
}).getUserMessages!(mockState, { frame })
)
).toHaveLength(0);
});
test('should not return warning messages when the data table is empty', () => {
frame.activeData = {
first: {
type: 'datatable',
rows: [],
columns: [],
},
};
const mockState = {
shape: CHART_SHAPES.HEATMAP,
valueAccessor: 'v-accessor',
layerId: 'first',
} as HeatmapVisualizationState;
expect(
onlyWarnings(
getHeatmapVisualization({
paletteService,
theme,
}).getUserMessages!(mockState, { frame })
)
).toHaveLength(0);
});
test('should return a warning message when cell value data contains arrays', () => {
frame.activeData = {
first: {
type: 'datatable',
rows: [
{
'v-accessor': [1, 2, 3],
},
],
columns: [],
},
};
const mockState = {
shape: CHART_SHAPES.HEATMAP,
valueAccessor: 'v-accessor',
layerId: 'first',
} as HeatmapVisualizationState;
expect(
onlyWarnings(
getHeatmapVisualization({
paletteService,
theme,
}).getUserMessages!(mockState, { frame })
)
).toHaveLength(1);
});
});
});
});

View file

@ -24,7 +24,7 @@ import {
HeatmapLegendExpressionFunctionDefinition,
} from '@kbn/expression-heatmap-plugin/common';
import { buildExpression, buildExpressionFunction } from '@kbn/expressions-plugin/common';
import type { OperationMetadata, Suggestion, Visualization } from '../../types';
import type { OperationMetadata, Suggestion, UserMessage, Visualization } from '../../types';
import type { HeatmapVisualizationState } from './types';
import { getSuggestions } from './suggestions';
import {
@ -433,16 +433,19 @@ export const getHeatmapVisualization = ({
};
},
getErrorMessages(state) {
getUserMessages(state, { frame }) {
if (!state.yAccessor && !state.xAccessor && !state.valueAccessor) {
// nothing configured yet
return;
return [];
}
const errors: ReturnType<Visualization['getErrorMessages']> = [];
const errors: UserMessage[] = [];
if (!state.xAccessor) {
errors.push({
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate(
'xpack.lens.heatmapVisualization.missingXAccessorShortMessage',
{
@ -455,33 +458,37 @@ export const getHeatmapVisualization = ({
});
}
return errors.length ? errors : undefined;
},
let warnings: UserMessage[] = [];
getWarningMessages(state, frame) {
if (!state?.layerId || !frame.activeData || !state.valueAccessor) {
return;
if (state?.layerId && frame.activeData && state.valueAccessor) {
const rows = frame.activeData[state.layerId] && frame.activeData[state.layerId].rows;
if (rows) {
const hasArrayValues = rows.some((row) => Array.isArray(row[state.valueAccessor!]));
const datasource = frame.datasourceLayers[state.layerId];
const operation = datasource?.getOperationForColumnId(state.valueAccessor);
warnings = hasArrayValues
? [
{
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
id="xpack.lens.heatmapVisualization.arrayValuesWarningMessage"
defaultMessage="{label} contains array values. Your visualization may not render as expected."
values={{ label: <strong>{operation?.label}</strong> }}
/>
),
},
]
: [];
}
}
const rows = frame.activeData[state.layerId] && frame.activeData[state.layerId].rows;
if (!rows) {
return;
}
const hasArrayValues = rows.some((row) => Array.isArray(row[state.valueAccessor!]));
const datasource = frame.datasourceLayers[state.layerId];
const operation = datasource?.getOperationForColumnId(state.valueAccessor);
return hasArrayValues
? [
<FormattedMessage
id="xpack.lens.heatmapVisualization.arrayValuesWarningMessage"
defaultMessage="{label} contains array values. Your visualization may not render as expected."
values={{ label: <strong>{operation?.label}</strong> }}
/>,
]
: undefined;
return [...errors, ...warnings];
},
getSuggestionFromConvertToLensContext({ suggestions, context }) {

View file

@ -384,12 +384,4 @@ describe('metric_visualization', () => {
`);
});
});
describe('#getErrorMessages', () => {
it('returns undefined if no error is raised', () => {
const error = metricVisualization.getErrorMessages(exampleState());
expect(error).not.toBeDefined();
});
});
});

View file

@ -312,11 +312,6 @@ export const getLegacyMetricVisualization = ({
);
},
getErrorMessages(state) {
// Is it possible to break it?
return undefined;
},
getVisualizationInfo(state: LegacyMetricState) {
const dimensions = [];
if (state.accessor) {

View file

@ -634,11 +634,6 @@ export const getMetricVisualization = ({
);
},
getErrorMessages(state) {
// Is it possible to break it?
return undefined;
},
getDisplayOptions() {
return {
noPanelTitle: true,

View file

@ -72,7 +72,7 @@ function mockFrame(): FramePublicAPI {
describe('pie_visualization', () => {
beforeEach(() => jest.clearAllMocks());
describe('#getErrorMessages', () => {
describe('#getUserMessages', () => {
describe('too many dimensions', () => {
const state = { ...getExampleState(), shape: PieChartTypes.MOSAIC };
const colIds = new Array(PartitionChartsMeta.mosaic.maxBuckets + 1)
@ -83,7 +83,9 @@ describe('pie_visualization', () => {
state.layers[0].secondaryGroups = colIds.slice(2);
it('returns error', () => {
expect(pieVisualization.getErrorMessages(state)).toHaveLength(1);
expect(
pieVisualization.getUserMessages!(state, { frame: {} as FramePublicAPI })
).toHaveLength(1);
});
it("doesn't count collapsed dimensions", () => {
@ -92,17 +94,23 @@ describe('pie_visualization', () => {
[colIds[0]]: 'some-fn' as CollapseFunction,
};
expect(pieVisualization.getErrorMessages(localState)).toHaveLength(0);
expect(
pieVisualization.getUserMessages!(localState, { frame: {} as FramePublicAPI })
).toHaveLength(0);
});
it('counts multiple metrics as an extra bucket dimension', () => {
const localState = cloneDeep(state);
localState.layers[0].primaryGroups.pop();
expect(pieVisualization.getErrorMessages(localState)).toHaveLength(0);
expect(
pieVisualization.getUserMessages!(localState, { frame: {} as FramePublicAPI })
).toHaveLength(0);
localState.layers[0].metrics.push('one-metric', 'another-metric');
expect(pieVisualization.getErrorMessages(localState)).toHaveLength(1);
expect(
pieVisualization.getUserMessages!(localState, { frame: {} as FramePublicAPI })
).toHaveLength(1);
});
});
});

View file

@ -25,6 +25,7 @@ import type {
Suggestion,
VisualizeEditorContext,
VisualizationInfo,
UserMessage,
} from '../../types';
import {
getColumnToLabelMap,
@ -508,66 +509,6 @@ export const getPieVisualization = ({
);
},
getWarningMessages(state, frame) {
if (state?.layers.length === 0 || !frame.activeData) {
return;
}
const warningMessages = [];
for (const layer of state.layers) {
const { layerId, metrics } = layer;
const rows = frame.activeData[layerId]?.rows;
const numericColumn = frame.activeData[layerId]?.columns.find(
({ meta }) => meta?.type === 'number'
);
if (!rows || !metrics.length) {
break;
}
if (
numericColumn &&
state.shape === 'waffle' &&
layer.primaryGroups.length &&
checkTableForContainsSmallValues(frame.activeData[layerId], numericColumn.id, 1)
) {
warningMessages.push(
<FormattedMessage
id="xpack.lens.pie.smallValuesWarningMessage"
defaultMessage="Waffle charts are unable to effectively display small field values. To display all field values, use the Data table or Treemap."
/>
);
}
const metricsWithArrayValues = metrics
.map((metricColId) => {
if (rows.some((row) => Array.isArray(row[metricColId]))) {
return metricColId;
}
})
.filter(Boolean) as string[];
if (metricsWithArrayValues.length) {
const labels = metricsWithArrayValues.map(
(colId) => frame.datasourceLayers[layerId]?.getOperationForColumnId(colId)?.label || colId
);
warningMessages.push(
<FormattedMessage
key={labels.join(',')}
id="xpack.lens.pie.arrayValues"
defaultMessage="The following dimensions contain array values: {label}. Your visualization may not render as
expected."
values={{
label: <strong>{labels.join(', ')}</strong>,
}}
/>
);
}
}
return warningMessages;
},
getSuggestionFromConvertToLensContext(props) {
const context = props.context;
if (!isPartitionVisConfiguration(context)) {
@ -592,7 +533,7 @@ export const getPieVisualization = ({
return suggestion;
},
getErrorMessages(state) {
getUserMessages(state, { frame }) {
const hasTooManyBucketDimensions = state.layers
.map((layer) => {
const totalBucketDimensions =
@ -605,9 +546,12 @@ export const getPieVisualization = ({
})
.some(Boolean);
return hasTooManyBucketDimensions
const errors: UserMessage[] = hasTooManyBucketDimensions
? [
{
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate('xpack.lens.pie.tooManyDimensions', {
defaultMessage: 'Your visualization has too many dimensions.',
}),
@ -626,6 +570,75 @@ export const getPieVisualization = ({
},
]
: [];
const warningMessages: UserMessage[] = [];
if (state?.layers.length > 0 && frame.activeData) {
for (const layer of state.layers) {
const { layerId, metrics } = layer;
const rows = frame.activeData[layerId]?.rows;
const numericColumn = frame.activeData[layerId]?.columns.find(
({ meta }) => meta?.type === 'number'
);
if (!rows || !metrics.length) {
break;
}
if (
numericColumn &&
state.shape === 'waffle' &&
layer.primaryGroups.length &&
checkTableForContainsSmallValues(frame.activeData[layerId], numericColumn.id, 1)
) {
warningMessages.push({
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
id="xpack.lens.pie.smallValuesWarningMessage"
defaultMessage="Waffle charts are unable to effectively display small field values. To display all field values, use the Data table or Treemap."
/>
),
});
}
const metricsWithArrayValues = metrics
.map((metricColId) => {
if (rows.some((row) => Array.isArray(row[metricColId]))) {
return metricColId;
}
})
.filter(Boolean) as string[];
if (metricsWithArrayValues.length) {
const labels = metricsWithArrayValues.map(
(colId) =>
frame.datasourceLayers[layerId]?.getOperationForColumnId(colId)?.label || colId
);
warningMessages.push({
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
key={labels.join(',')}
id="xpack.lens.pie.arrayValues"
defaultMessage="The following dimensions contain array values: {label}. Your visualization may not render as
expected."
values={{
label: <strong>{labels.join(', ')}</strong>,
}}
/>
),
});
}
}
}
return [...errors, ...warningMessages];
},
getVisualizationInfo(state: PieVisualizationState) {

View file

@ -44,7 +44,7 @@ export const defaultRangeAnnotationLabel = i18n.translate(
}
);
const isDateHistogram = (
export const isDateHistogram = (
dataLayers: XYDataLayerConfig[],
frame?: Pick<FramePublicAPI, 'activeData' | 'datasourceLayers'> | undefined
) =>
@ -475,8 +475,6 @@ export const getAnnotationsConfiguration = ({
frame: Pick<FramePublicAPI, 'datasourceLayers'>;
layer: XYAnnotationLayerConfig;
}) => {
const hasDateHistogram = isDateHistogram(getDataLayers(state.layers), frame);
const groupLabel = getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) });
const emptyButtonLabels = {
@ -503,10 +501,6 @@ export const getAnnotationsConfiguration = ({
),
accessors: getAnnotationsAccessorColorConfig(layer),
dataTestSubj: 'lnsXY_xAnnotationsPanel',
invalid: !hasDateHistogram,
invalidMessage: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', {
defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.',
}),
requiredMinDimensionCount: 0,
supportsMoreColumns: true,
supportFieldFormat: false,

View file

@ -12,12 +12,8 @@ import { isQueryAnnotationConfig } from '@kbn/event-annotation-plugin/public';
import { i18n } from '@kbn/i18n';
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { validateQuery } from '../../shared_components';
import type {
FramePublicAPI,
DatasourcePublicAPI,
VisualizationDimensionGroupConfig,
VisualizeEditorContext,
} from '../../types';
import { DataViewsState } from '../../state_management';
import type { FramePublicAPI, DatasourcePublicAPI, VisualizeEditorContext } from '../../types';
import {
visualizationTypes,
XYLayerConfig,
@ -27,7 +23,6 @@ import {
YConfig,
XYState,
XYPersistedState,
State,
XYAnnotationLayerConfig,
} from './types';
import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers';
@ -177,29 +172,18 @@ function getIndexPatternIdFromInitialContext(
}
}
export function validateColumn(
state: State,
frame: Pick<FramePublicAPI, 'dataViews'>,
layerId: string,
export function getAnnotationLayerErrors(
layer: XYAnnotationLayerConfig,
columnId: string,
group?: VisualizationDimensionGroupConfig
): { invalid: boolean; invalidMessages?: string[] } {
if (group?.invalid) {
return {
invalid: true,
invalidMessages: group.invalidMessage ? [group.invalidMessage] : undefined,
};
}
const validColumn = { invalid: false };
const layer = state.layers.find((l) => l.layerId === layerId);
if (!layer || !isAnnotationsLayer(layer)) {
return validColumn;
dataViews: DataViewsState
): string[] {
if (!layer) {
return [];
}
const annotation = layer.annotations.find(({ id }) => id === columnId);
if (!annotation || !isQueryAnnotationConfig(annotation)) {
return validColumn;
return [];
}
const { dataViews } = frame || {};
const layerDataView = dataViews.indexPatterns[layer.indexPatternId];
const invalidMessages: string[] = [];
@ -255,11 +239,5 @@ export function validateColumn(
}
}
if (!invalidMessages.length) {
return validColumn;
}
return {
invalid: true,
invalidMessages,
};
return invalidMessages;
}

View file

@ -35,7 +35,13 @@ import {
DimensionEditor,
} from './xy_config_panel/dimension_editor';
import { LayerHeader, LayerHeaderContent } from './xy_config_panel/layer_header';
import type { Visualization, AccessorConfig, FramePublicAPI, Suggestion } from '../../types';
import type {
Visualization,
AccessorConfig,
FramePublicAPI,
Suggestion,
UserMessage,
} from '../../types';
import type { FormBasedPersistedState } from '../../datasources/form_based/types';
import {
type State,
@ -48,9 +54,9 @@ import {
} from './types';
import {
extractReferences,
getAnnotationLayerErrors,
injectReferences,
isHorizontalChart,
validateColumn,
} from './state_helpers';
import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression';
import { getAccessorColorConfigs, getColorAssignments } from './color_assignment';
@ -67,6 +73,7 @@ import {
setAnnotationsDimension,
getUniqueLabels,
onAnnotationDrop,
isDateHistogram,
} from './annotations/helpers';
import {
checkXAccessorCompatibility,
@ -692,52 +699,58 @@ export const getXyVisualization = ({
eventAnnotationService
),
validateColumn(state, frame, layerId, columnId, group) {
const { invalid, invalidMessages } = validateColumn(state, frame, layerId, columnId, group);
if (!invalid) {
return { invalid };
}
return { invalid, invalidMessage: invalidMessages![0] };
},
getErrorMessages(state, frame) {
const { datasourceLayers, dataViews } = frame || {};
const errors: Array<{
shortMessage: string;
longMessage: React.ReactNode;
}> = [];
getUserMessages(state, { frame }) {
const { datasourceLayers, dataViews, activeData } = frame;
const annotationLayers = getAnnotationsLayers(state.layers);
const errors: UserMessage[] = [];
if (dataViews) {
annotationLayers.forEach((layer) => {
layer.annotations.forEach((annotation) => {
const validatedColumn = validateColumn(
state,
{ dataViews },
layer.layerId,
annotation.id
);
if (validatedColumn?.invalid && validatedColumn.invalidMessages?.length) {
errors.push(
...validatedColumn.invalidMessages.map((invalidMessage) => ({
shortMessage: invalidMessage,
longMessage: (
<FormattedMessage
id="xpack.lens.xyChart.annotationError"
defaultMessage="Annotation {annotationName} has an error: {errorMessage}"
values={{
annotationName: annotation.label,
errorMessage: invalidMessage,
}}
/>
),
}))
);
}
});
const hasDateHistogram = isDateHistogram(getDataLayers(state.layers), frame);
annotationLayers.forEach((layer) => {
layer.annotations.forEach((annotation) => {
if (!hasDateHistogram) {
errors.push({
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'dimensionTrigger', dimensionId: annotation.id }],
shortMessage: i18n.translate(
'xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp',
{
defaultMessage:
'Annotations require a time based chart to work. Add a date histogram.',
}
),
longMessage: '',
});
}
const errorMessages = getAnnotationLayerErrors(layer, annotation.id, dataViews);
errors.push(
...errorMessages.map((errorMessage) => {
const message: UserMessage = {
severity: 'error',
fixableInEditor: true,
displayLocations: [
{ id: 'visualization' },
{ id: 'dimensionTrigger', dimensionId: annotation.id },
],
shortMessage: errorMessage,
longMessage: (
<FormattedMessage
id="xpack.lens.xyChart.annotationError"
defaultMessage="Annotation {annotationName} has an error: {errorMessage}"
values={{
annotationName: annotation.label,
errorMessage,
}}
/>
),
};
return message;
})
);
});
}
});
// Data error handling below here
const hasNoAccessors = ({ accessors }: XYDataLayerConfig) =>
@ -763,84 +776,108 @@ export const getXyVisualization = ({
for (const [dimension, criteria] of checks) {
const result = validateLayersForDimension(dimension, filteredLayers, criteria);
if (!result.valid) {
errors.push(result.payload);
errors.push({
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: result.payload.shortMessage,
longMessage: result.payload.longMessage,
});
}
}
}
// temporary fix for #87068
errors.push(
...checkXAccessorCompatibility(state, datasourceLayers).map(
({ shortMessage, longMessage }) =>
({
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage,
longMessage,
} as UserMessage)
)
);
if (datasourceLayers && state) {
// temporary fix for #87068
errors.push(...checkXAccessorCompatibility(state, datasourceLayers));
for (const layer of getDataLayers(state.layers)) {
const datasourceAPI = datasourceLayers[layer.layerId];
if (datasourceAPI) {
for (const accessor of layer.accessors) {
const operation = datasourceAPI.getOperationForColumnId(accessor);
if (operation && operation.dataType !== 'number') {
errors.push({
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureYShort', {
defaultMessage: `Wrong data type for {axis}.`,
values: {
axis: getAxisName('y', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureYLong', {
defaultMessage: `The dimension {label} provided for the {axis} has the wrong data type. Expected number but have {dataType}`,
values: {
label: operation.label,
dataType: operation.dataType,
axis: getAxisName('y', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
});
}
for (const layer of getDataLayers(state.layers)) {
const datasourceAPI = datasourceLayers[layer.layerId];
if (datasourceAPI) {
for (const accessor of layer.accessors) {
const operation = datasourceAPI.getOperationForColumnId(accessor);
if (operation && operation.dataType !== 'number') {
errors.push({
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureYShort', {
defaultMessage: `Wrong data type for {axis}.`,
values: {
axis: getAxisName('y', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureYLong', {
defaultMessage: `The dimension {label} provided for the {axis} has the wrong data type. Expected number but have {dataType}`,
values: {
label: operation.label,
dataType: operation.dataType,
axis: getAxisName('y', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
});
}
}
}
}
return errors.length ? errors : undefined;
},
const warnings: UserMessage[] = [];
getWarningMessages(state, frame) {
if (state?.layers.length === 0 || !frame.activeData) {
return;
}
if (state?.layers.length > 0 && activeData) {
const filteredLayers = [
...getDataLayers(state.layers),
...getReferenceLayers(state.layers),
].filter(({ accessors }) => accessors.length > 0);
const filteredLayers = [
...getDataLayers(state.layers),
...getReferenceLayers(state.layers),
].filter(({ accessors }) => accessors.length > 0);
const accessorsWithArrayValues = [];
const accessorsWithArrayValues = [];
for (const layer of filteredLayers) {
const { layerId, accessors } = layer;
const rows = frame.activeData?.[layerId] && frame.activeData[layerId].rows;
if (!rows) {
break;
}
const columnToLabel = getColumnToLabelMap(layer, frame.datasourceLayers[layerId]);
for (const accessor of accessors) {
const hasArrayValues = rows.some((row) => Array.isArray(row[accessor]));
if (hasArrayValues) {
accessorsWithArrayValues.push(columnToLabel[accessor]);
for (const layer of filteredLayers) {
const { layerId, accessors } = layer;
const rows = activeData?.[layerId] && activeData[layerId].rows;
if (!rows) {
break;
}
const columnToLabel = getColumnToLabelMap(layer, datasourceLayers[layerId]);
for (const accessor of accessors) {
const hasArrayValues = rows.some((row) => Array.isArray(row[accessor]));
if (hasArrayValues) {
accessorsWithArrayValues.push(columnToLabel[accessor]);
}
}
}
accessorsWithArrayValues.forEach((label) =>
warnings.push({
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
key={label}
id="xpack.lens.xyVisualization.arrayValues"
defaultMessage="{label} contains array values. Your visualization may not render as expected."
values={{
label: <strong>{label}</strong>,
}}
/>
),
})
);
}
return accessorsWithArrayValues.map((label) => (
<FormattedMessage
key={label}
id="xpack.lens.xyVisualization.arrayValues"
defaultMessage="{label} contains array values. Your visualization may not render as expected."
values={{
label: <strong>{label}</strong>,
}}
/>
));
return [...errors, ...warnings];
},
getUniqueLabels(state) {
return getUniqueLabels(state.layers);
},
@ -852,19 +889,7 @@ export const getXyVisualization = ({
state?.layers.filter(isAnnotationsLayer).map(({ indexPatternId }) => indexPatternId) ?? []
);
},
renderDimensionTrigger({
columnId,
label,
hideTooltip,
invalid,
invalidMessage,
}: {
columnId: string;
label?: string;
hideTooltip?: boolean;
invalid?: boolean;
invalidMessage?: string;
}) {
renderDimensionTrigger({ columnId, label, hideTooltip, invalid, invalidMessage }) {
if (label) {
return (
<DimensionTrigger

View file

@ -211,8 +211,4 @@ export const getVisualization = ({
);
}
},
getErrorMessages(state) {
return undefined;
},
});

View file

@ -540,16 +540,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'cumulative_sum',
});
expect(await PageObjects.lens.getErrorCount()).to.eql(1);
expect(await PageObjects.lens.getWorkspaceErrorCount()).to.eql(1);
await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel');
expect(await PageObjects.lens.getErrorCount()).to.eql(2);
expect(await PageObjects.lens.getWorkspaceErrorCount()).to.eql(2);
await PageObjects.lens.dragFieldToDimensionTrigger(
'@timestamp',
'lnsXY_xDimensionPanel > lns-empty-dimension'
);
expect(await PageObjects.lens.getErrorCount()).to.eql(1);
expect(await PageObjects.lens.getWorkspaceErrorCount()).to.eql(1);
expect(await PageObjects.lens.hasChartSwitchWarning('lnsDatatable')).to.eql(false);
await PageObjects.lens.switchToVisualization('lnsDatatable');

View file

@ -136,7 +136,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
formula: `asdf`,
});
expect(await PageObjects.lens.getErrorCount()).to.eql(1);
expect(await PageObjects.lens.getWorkspaceErrorCount()).to.eql(1);
});
it('should keep the formula when entering expanded mode', async () => {
@ -175,7 +175,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
await PageObjects.lens.waitForVisualization('legacyMtrVis');
expect(await PageObjects.lens.getErrorCount()).to.eql(0);
expect(await PageObjects.lens.getWorkspaceErrorCount()).to.eql(0);
});
it('should duplicate a moving average formula and be a valid table with conditional coloring', async () => {

View file

@ -840,17 +840,16 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
},
/** Counts the visible warnings in the config panel */
async getErrorCount() {
const moreButton = await testSubjects.exists('configuration-failure-more-errors');
async getWorkspaceErrorCount() {
const moreButton = await testSubjects.exists('workspace-more-errors-button');
if (moreButton) {
await retry.try(async () => {
await testSubjects.click('configuration-failure-more-errors');
await testSubjects.missingOrFail('configuration-failure-more-errors');
await testSubjects.click('workspace-more-errors-button');
await testSubjects.missingOrFail('workspace-more-errors-button');
});
}
const errors = await testSubjects.findAll('configuration-failure-error');
const expressionErrors = await testSubjects.findAll('expression-failure');
return (errors?.length ?? 0) + (expressionErrors?.length ?? 0);
const errors = await testSubjects.findAll('workspace-error-message');
return errors?.length ?? 0;
},
async searchOnChartSwitch(subVisualizationId: string, searchTerm?: string) {