[Lens] Improves lost indexpattern scenario in saved visualization (#91377)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Liberati 2021-03-18 14:29:17 +01:00 committed by GitHub
parent 49aef21bd4
commit 8f6a6e2ae5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 518 additions and 141 deletions

View file

@ -213,8 +213,8 @@ export const getOutputSubscription = ({
}),
distinctUntilChanged((a, b) =>
deepEqual(
a.map((ip) => ip.id),
b.map((ip) => ip.id)
a.map((ip) => ip && ip.id),
b.map((ip) => ip && ip.id)
)
),
// using switchMap for previous task cancellation

View file

@ -10,7 +10,7 @@ import './app.scss';
import _ from 'lodash';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { NotificationsStart, Toast } from 'kibana/public';
import { Toast } from 'kibana/public';
import { VisualizeFieldContext } from 'src/plugins/ui_actions/public';
import { Datatable } from 'src/plugins/expressions/public';
import { EuiBreadcrumb } from '@elastic/eui';
@ -334,12 +334,11 @@ export function App({
initialInput.savedObjectId
);
}
getAllIndexPatterns(
_.uniq(doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)),
data.indexPatterns,
notifications
)
.then((indexPatterns) => {
const indexPatternIds = _.uniq(
doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
);
getAllIndexPatterns(indexPatternIds, data.indexPatterns)
.then(({ indexPatterns }) => {
// Don't overwrite any pinned filters
data.query.filterManager.setAppFilters(
injectFilterReferences(doc.state.filters, doc.references)
@ -683,7 +682,6 @@ export function App({
initialContext={initialContext}
setState={setState}
data={data}
notifications={notifications}
query={state.query}
filters={state.filters}
searchSessionId={state.searchSessionId}
@ -736,7 +734,6 @@ const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({
initialContext,
setState,
data,
notifications,
lastKnownDoc,
activeData: activeDataRef,
}: {
@ -754,7 +751,6 @@ const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({
initialContext: VisualizeFieldContext | undefined;
setState: React.Dispatch<React.SetStateAction<LensAppState>>;
data: DataPublicPluginStart;
notifications: NotificationsStart;
lastKnownDoc: React.MutableRefObject<Document | undefined>;
activeData: React.MutableRefObject<Record<string, Datatable> | undefined>;
}) {
@ -790,8 +786,8 @@ const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({
(id) => !indexPatternsForTopNav.find((indexPattern) => indexPattern.id === id)
)
) {
getAllIndexPatterns(filterableIndexPatterns, data.indexPatterns, notifications).then(
(indexPatterns) => {
getAllIndexPatterns(filterableIndexPatterns, data.indexPatterns).then(
({ indexPatterns }) => {
if (indexPatterns) {
setState((s) => ({ ...s, indexPatternsForTopNav: indexPatterns }));
}
@ -806,18 +802,16 @@ const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({
export async function getAllIndexPatterns(
ids: string[],
indexPatternsService: IndexPatternsContract,
notifications: NotificationsStart
): Promise<IndexPatternInstance[]> {
try {
return await Promise.all(ids.map((id) => indexPatternsService.get(id)));
} catch (e) {
notifications.toasts.addDanger(
i18n.translate('xpack.lens.app.indexPatternLoadingError', {
defaultMessage: 'Error loading index patterns',
})
);
throw new Error(e);
}
indexPatternsService: IndexPatternsContract
): Promise<{ indexPatterns: IndexPatternInstance[]; rejectedIds: string[] }> {
const responses = await Promise.allSettled(ids.map((id) => indexPatternsService.get(id)));
const fullfilled = responses.filter(
(response): response is PromiseFulfilledResult<IndexPatternInstance> =>
response.status === 'fulfilled'
);
const rejectedIds = responses
.map((_response, i) => ids[i])
.filter((id, i) => responses[i].status === 'rejected');
// return also the rejected ids in case we want to show something later on
return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds };
}

View file

@ -14,9 +14,16 @@ import { ReactWrapper } from 'enzyme';
jest.mock('react-virtualized-auto-sizer', () => {
return function (props: {
children: (dimensions: { width: number; height: number }) => React.ReactNode;
disableHeight?: boolean;
}) {
const { children, ...otherProps } = props;
return <div {...otherProps}>{children({ width: 100, height: 100 })}</div>;
const { children, disableHeight, ...otherProps } = props;
return (
// js-dom may complain that a non-DOM attributes are used when appending props
// Handle the disableHeight case using native DOM styling
<div {...otherProps} style={disableHeight ? { height: 0 } : {}}>
{children({ width: 100, height: 100 })}
</div>
);
};
});
@ -66,7 +73,7 @@ function getDefaultProps() {
dateRange: { fromDate: '', toDate: '' },
query: { query: '', language: 'lucene' },
filters: [],
core: coreMock.createSetup(),
core: coreMock.createStart(),
plugins: {
uiActions: uiActionsPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),

View file

@ -6,7 +6,7 @@
*/
import React, { useEffect, useReducer, useState, useCallback } from 'react';
import { CoreSetup, CoreStart } from 'kibana/public';
import { CoreStart } from 'kibana/public';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
import { Datasource, FramePublicAPI, Visualization } from '../../types';
@ -40,7 +40,7 @@ export interface EditorFrameProps {
ExpressionRenderer: ReactExpressionRendererType;
palettes: PaletteRegistry;
onError: (e: { message: string }) => void;
core: CoreSetup | CoreStart;
core: CoreStart;
plugins: EditorFrameStartPlugins;
dateRange: {
fromDate: string;

View file

@ -20,7 +20,11 @@ import { Document } from '../../persistence/saved_object_store';
import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public';
import { getActiveDatasourceIdFromDoc } from './state_management';
import { ErrorMessage } from '../types';
import { getMissingCurrentDatasource, getMissingVisualizationTypeError } from '../error_helper';
import {
getMissingCurrentDatasource,
getMissingIndexPatterns,
getMissingVisualizationTypeError,
} from '../error_helper';
export async function initializeDatasources(
datasourceMap: Record<string, Datasource>,
@ -112,6 +116,19 @@ export async function persistedStateToExpression(
errors: [{ shortMessage: '', longMessage: getMissingCurrentDatasource() }],
};
}
const indexPatternValidation = validateRequiredIndexPatterns(
datasources[datasourceId],
datasourceStates[datasourceId]
);
if (indexPatternValidation) {
return {
ast: null,
errors: indexPatternValidation,
};
}
const validationResult = validateDatasourceAndVisualization(
datasources[datasourceId],
datasourceStates[datasourceId].state,
@ -134,6 +151,33 @@ export async function persistedStateToExpression(
};
}
export function getMissingIndexPattern(
currentDatasource: Datasource | null,
currentDatasourceState: { state: unknown } | null
) {
if (currentDatasourceState == null || currentDatasource == null) {
return [];
}
const missingIds = currentDatasource.checkIntegrity(currentDatasourceState.state);
if (!missingIds.length) {
return [];
}
return missingIds;
}
const validateRequiredIndexPatterns = (
currentDatasource: Datasource,
currentDatasourceState: { state: unknown } | null
): ErrorMessage[] | undefined => {
const missingIds = getMissingIndexPattern(currentDatasource, currentDatasourceState);
if (!missingIds.length) {
return;
}
return [{ shortMessage: '', longMessage: getMissingIndexPatterns(missingIds), type: 'fixable' }];
};
export const validateDatasourceAndVisualization = (
currentDataSource: Datasource | null,
currentDatasourceState: unknown | null,

View file

@ -28,7 +28,7 @@ describe('editor_frame state management', () => {
initialVisualizationId: 'testVis',
ExpressionRenderer: createExpressionRendererMock(),
onChange: jest.fn(),
core: coreMock.createSetup(),
core: coreMock.createStart(),
plugins: {
uiActions: uiActionsPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),

View file

@ -291,4 +291,24 @@ describe('suggestion_panel', () => {
expect(wrapper.find(EuiIcon)).toHaveLength(1);
expect(wrapper.find(EuiIcon).prop('type')).toEqual(LensIconChartDatatable);
});
it('should return no suggestion if visualization has missing index-patterns', () => {
// create a layer that is referencing an indexPatterns not retrieved by the datasource
const missingIndexPatternsState = {
layers: { indexPatternId: 'a' },
indexPatterns: {},
};
mockDatasource.checkIntegrity.mockReturnValue(['a']);
const newProps = {
...defaultProps,
datasourceStates: {
mock: {
...defaultProps.datasourceStates.mock,
state: missingIndexPatternsState,
},
},
};
const wrapper = mount(<SuggestionPanel {...newProps} />);
expect(wrapper.html()).toEqual(null);
});
});

View file

@ -34,7 +34,7 @@ import {
} from '../../../../../../src/plugins/expressions/public';
import { prependDatasourceExpression } from './expression_helpers';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import { validateDatasourceAndVisualization } from './state_helpers';
import { getMissingIndexPattern, validateDatasourceAndVisualization } from './state_helpers';
const MAX_SUGGESTIONS_DISPLAYED = 5;
@ -182,46 +182,52 @@ export function SuggestionPanel({
? stagedPreview.visualization.activeId
: activeVisualizationId;
const missingIndexPatterns = getMissingIndexPattern(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId ? datasourceStates[activeDatasourceId] : null
);
const { suggestions, currentStateExpression, currentStateError } = useMemo(
() => {
const newSuggestions = getSuggestions({
datasourceMap,
datasourceStates: currentDatasourceStates,
visualizationMap,
activeVisualizationId: currentVisualizationId,
visualizationState: currentVisualizationState,
activeData: frame.activeData,
})
.filter((suggestion) => !suggestion.hide)
.filter(
({
visualizationId,
visualizationState: suggestionVisualizationState,
datasourceState: suggestionDatasourceState,
datasourceId: suggetionDatasourceId,
}) => {
return (
validateDatasourceAndVisualization(
suggetionDatasourceId ? datasourceMap[suggetionDatasourceId] : null,
suggestionDatasourceState,
visualizationMap[visualizationId],
suggestionVisualizationState,
frame
) == null
);
}
)
.slice(0, MAX_SUGGESTIONS_DISPLAYED)
.map((suggestion) => ({
...suggestion,
previewExpression: preparePreviewExpression(
suggestion,
visualizationMap[suggestion.visualizationId],
const newSuggestions = missingIndexPatterns.length
? []
: getSuggestions({
datasourceMap,
currentDatasourceStates,
frame
),
}));
datasourceStates: currentDatasourceStates,
visualizationMap,
activeVisualizationId: currentVisualizationId,
visualizationState: currentVisualizationState,
activeData: frame.activeData,
})
.filter((suggestion) => !suggestion.hide)
.filter(
({
visualizationId,
visualizationState: suggestionVisualizationState,
datasourceState: suggestionDatasourceState,
datasourceId: suggetionDatasourceId,
}) => {
return (
validateDatasourceAndVisualization(
suggetionDatasourceId ? datasourceMap[suggetionDatasourceId] : null,
suggestionDatasourceState,
visualizationMap[visualizationId],
suggestionVisualizationState,
frame
) == null
);
}
)
.slice(0, MAX_SUGGESTIONS_DISPLAYED)
.map((suggestion) => ({
...suggestion,
previewExpression: preparePreviewExpression(
suggestion,
visualizationMap[suggestion.visualizationId],
datasourceMap,
currentDatasourceStates,
frame
),
}));
const validationErrors = validateDatasourceAndVisualization(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,

View file

@ -41,6 +41,20 @@ import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/pub
import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public/embeddable';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
const defaultPermissions: Record<string, Record<string, boolean | Record<string, boolean>>> = {
navLinks: { management: true },
management: { kibana: { indexPatterns: true } },
};
function createCoreStartWithPermissions(newCapabilities = defaultPermissions) {
const core = coreMock.createStart();
((core.application.capabilities as unknown) as Record<
string,
Record<string, boolean | Record<string, boolean>>
>) = newCapabilities;
return core;
}
describe('workspace_panel', () => {
let mockVisualization: jest.Mocked<Visualization>;
let mockVisualization2: jest.Mocked<Visualization>;
@ -84,7 +98,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -108,7 +122,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -132,7 +146,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -170,7 +184,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -211,7 +225,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -255,7 +269,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={dispatch}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -307,7 +321,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -386,7 +400,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -443,7 +457,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -471,6 +485,130 @@ describe('workspace_panel', () => {
expect(expressionRendererMock).toHaveBeenCalledTimes(2);
});
it('should show an error message if there are missing indexpatterns in the visualization', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource.checkIntegrity.mockReturnValue(['a']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
<WorkspacePanel
activeDatasourceId={'mock'}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
}}
framePublicAPI={framePublicAPI}
activeVisualizationId="vis"
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
);
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', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
<WorkspacePanel
activeDatasourceId={'mock'}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
}}
framePublicAPI={framePublicAPI}
activeVisualizationId="vis"
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
// Use cannot navigate to the management page
core={createCoreStartWithPermissions({
navLinks: { management: false },
management: { kibana: { indexPatterns: true } },
})}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
);
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', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
instance = mount(
<WorkspacePanel
activeDatasourceId={'mock'}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
}}
framePublicAPI={framePublicAPI}
activeVisualizationId="vis"
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
// user can go to management, but indexPatterns management is not accessible
core={createCoreStartWithPermissions({
navLinks: { management: true },
management: { kibana: { indexPatterns: false } },
})}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
);
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', () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
@ -501,7 +639,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -543,7 +681,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -587,7 +725,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -628,7 +766,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -667,7 +805,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -712,7 +850,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={() => {}}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={() => undefined}
/>
@ -781,7 +919,7 @@ describe('workspace_panel', () => {
visualizationState={{}}
dispatch={mockDispatch}
ExpressionRenderer={expressionRendererMock}
core={coreMock.createSetup()}
core={createCoreStartWithPermissions()}
plugins={{ uiActions: uiActionsMock, data: dataMock }}
getSuggestionForField={mockGetSuggestionForField}
/>

View file

@ -19,12 +19,13 @@ import {
EuiLink,
EuiPageContentBody,
} from '@elastic/eui';
import { CoreStart, CoreSetup } from 'kibana/public';
import { CoreStart, ApplicationStart } from 'kibana/public';
import {
DataPublicPluginStart,
ExecutionContextSearch,
TimefilterContract,
} from 'src/plugins/data/public';
import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public';
import {
ExpressionRendererEvent,
ExpressionRenderError,
@ -52,7 +53,7 @@ import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualiza
import { WorkspacePanelWrapper } from './workspace_panel_wrapper';
import { DropIllustration } from '../../../assets/drop_illustration';
import { getOriginalRequestErrorMessage } from '../../error_helper';
import { validateDatasourceAndVisualization } from '../state_helpers';
import { getMissingIndexPattern, validateDatasourceAndVisualization } from '../state_helpers';
import { DefaultInspectorAdapters } from '../../../../../../../src/plugins/expressions/common';
export interface WorkspacePanelProps {
@ -71,7 +72,7 @@ export interface WorkspacePanelProps {
framePublicAPI: FramePublicAPI;
dispatch: (action: Action) => void;
ExpressionRenderer: ReactExpressionRendererType;
core: CoreStart | CoreSetup;
core: CoreStart;
plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart };
title?: string;
visualizeTriggerFieldContext?: VisualizeFieldContext;
@ -139,6 +140,27 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
? visualizationMap[activeVisualizationId]
: null;
const missingIndexPatterns = getMissingIndexPattern(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId ? datasourceStates[activeDatasourceId] : null
);
const missingRefsErrors = missingIndexPatterns.length
? [
{
shortMessage: '',
longMessage: i18n.translate('xpack.lens.indexPattern.missingIndexPattern', {
defaultMessage:
'The {count, plural, one {index pattern} other {index patterns}} ({count, plural, one {id} other {ids}}: {indexpatterns}) cannot be found',
values: {
count: missingIndexPatterns.length,
indexpatterns: missingIndexPatterns.join(', '),
},
}),
},
]
: [];
// 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.
@ -157,7 +179,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
const expression = useMemo(
() => {
if (!configurationValidationError?.length) {
if (!configurationValidationError?.length && !missingRefsErrors.length) {
try {
const ast = buildExpression({
visualization: activeVisualization,
@ -310,8 +332,9 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
dispatch={dispatch}
onEvent={onEvent}
setLocalState={setLocalState}
localState={{ ...localState, configurationValidationError }}
localState={{ ...localState, configurationValidationError, missingRefsErrors }}
ExpressionRendererComponent={ExpressionRendererComponent}
application={core.application}
/>
);
};
@ -354,6 +377,7 @@ export const InnerVisualizationWrapper = ({
localState,
ExpressionRendererComponent,
dispatch,
application,
}: {
expression: string | null | undefined;
framePublicAPI: FramePublicAPI;
@ -363,8 +387,10 @@ export const InnerVisualizationWrapper = ({
setLocalState: (dispatch: (prevState: WorkspaceState) => WorkspaceState) => void;
localState: WorkspaceState & {
configurationValidationError?: Array<{ shortMessage: string; longMessage: string }>;
missingRefsErrors?: Array<{ shortMessage: string; longMessage: string }>;
};
ExpressionRendererComponent: ReactExpressionRendererType;
application: ApplicationStart;
}) => {
const context: ExecutionContextSearch = useMemo(
() => ({
@ -454,6 +480,52 @@ export const InnerVisualizationWrapper = ({
);
}
if (localState.missingRefsErrors?.length) {
// Check for access to both Management app && specific indexPattern section
const { management: isManagementEnabled } = application.capabilities.navLinks;
const isIndexPatternManagementEnabled =
application.capabilities.management.kibana.indexPatterns;
return (
<EuiFlexGroup data-test-subj="configuration-failure">
<EuiFlexItem>
<EuiEmptyPrompt
actions={
isManagementEnabled && isIndexPatternManagementEnabled ? (
<RedirectAppLinks application={application}>
<a
href={application.getUrlForApp('management', {
path: '/kibana/indexPatterns/create',
})}
data-test-subj="configuration-failure-reconfigure-indexpatterns"
>
{i18n.translate('xpack.lens.editorFrame.indexPatternReconfigure', {
defaultMessage: `Recreate it in the index pattern management page`,
})}
</a>
</RedirectAppLinks>
) : null
}
body={
<>
<p className="eui-textBreakAll" data-test-subj="missing-refs-failure">
<FormattedMessage
id="xpack.lens.editorFrame.indexPatternNotFound"
defaultMessage="Index pattern not found"
/>
</p>
<p className="eui-textBreakWord lnsSelectableErrorMessage">
{localState.missingRefsErrors[0].longMessage}
</p>
</>
}
iconColor="danger"
iconType="alert"
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (localState.expressionBuildError?.length) {
return (
<EuiFlexGroup>

View file

@ -530,7 +530,7 @@ describe('embeddable', () => {
attributeService,
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
indexPatternService: ({ get: jest.fn() } as unknown) as IndexPatternsContract,
editable: true,
getTrigger,
documentToExpression: () =>

View file

@ -296,6 +296,7 @@ export class Embeddable
hasCompatibleActions={this.hasCompatibleActions}
className={input.className}
style={input.style}
canEdit={this.deps.editable && input.viewMode === 'edit'}
/>,
domNode
);
@ -388,22 +389,19 @@ export class Embeddable
if (!this.savedVis) {
return;
}
const promises = _.uniqBy(
this.savedVis.references.filter(({ type }) => type === 'index-pattern'),
'id'
)
.map(async ({ id }) => {
try {
return await this.deps.indexPatternService.get(id);
} catch (error) {
// Unable to load index pattern, ignore error as the index patterns are only used to
// configure the filter and query bar - there is still a good chance to get the visualization
// to show.
return null;
}
})
.filter((promise): promise is Promise<IndexPattern> => Boolean(promise));
const indexPatterns = await Promise.all(promises);
const responses = await Promise.allSettled(
_.uniqBy(
this.savedVis.references.filter(({ type }) => type === 'index-pattern'),
'id'
).map(({ id }) => this.deps.indexPatternService.get(id))
);
const indexPatterns = responses
.filter(
(response): response is PromiseFulfilledResult<IndexPattern> =>
response.status === 'fulfilled'
)
.map(({ value }) => value);
// 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.

View file

@ -37,13 +37,17 @@ export interface ExpressionWrapperProps {
hasCompatibleActions?: ReactExpressionRendererProps['hasCompatibleActions'];
style?: React.CSSProperties;
className?: string;
canEdit: boolean;
}
interface VisualizationErrorProps {
errors: ExpressionWrapperProps['errors'];
canEdit: boolean;
}
export function VisualizationErrorPanel({ errors }: VisualizationErrorProps) {
export function VisualizationErrorPanel({ errors, canEdit }: VisualizationErrorProps) {
const showMore = errors && errors.length > 1;
const canFixInLens = canEdit && errors?.some(({ type }) => type === 'fixable');
return (
<div className="lnsEmbeddedError">
<EuiEmptyPrompt
@ -55,7 +59,7 @@ export function VisualizationErrorPanel({ errors }: VisualizationErrorProps) {
{errors ? (
<>
<p>{errors[0].longMessage}</p>
{errors.length > 1 ? (
{showMore && !canFixInLens ? (
<p>
<FormattedMessage
id="xpack.lens.embeddable.moreErrors"
@ -63,6 +67,14 @@ export function VisualizationErrorPanel({ errors }: VisualizationErrorProps) {
/>
</p>
) : null}
{canFixInLens ? (
<p>
<FormattedMessage
id="xpack.lens.embeddable.fixErrors"
defaultMessage="Edit in Lens editor to fix the error"
/>
</p>
) : null}
</>
) : (
<p>
@ -93,11 +105,12 @@ export function ExpressionWrapper({
style,
className,
errors,
canEdit,
}: ExpressionWrapperProps) {
return (
<I18nProvider>
{errors || expression === null || expression === '' ? (
<VisualizationErrorPanel errors={errors} />
<VisualizationErrorPanel errors={errors} canEdit={canEdit} />
) : (
<div className={classNames('lnsExpressionRenderer', className)} style={style}>
<ExpressionRendererComponent

View file

@ -87,3 +87,11 @@ export function getMissingCurrentDatasource() {
defaultMessage: 'Could not find datasource for the visualization',
});
}
export function getMissingIndexPatterns(indexPatternIds: string[]) {
return i18n.translate('xpack.lens.editorFrame.expressionMissingIndexPattern', {
defaultMessage:
'Could not find the {count, plural, one {index pattern} other {index pattern}}: {ids}',
values: { count: indexPatternIds.length, ids: indexPatternIds.join(', ') },
});
}

View file

@ -97,6 +97,7 @@ export function createMockDatasource(id: string): DatasourceMock {
// but can be used to validate whether specific API mock functions are called
publicAPIMock,
getErrorMessages: jest.fn((_state) => undefined),
checkIntegrity: jest.fn((_state) => []),
};
}

View file

@ -12,4 +12,5 @@ export type TableInspectorAdapter = Record<string, Datatable>;
export interface ErrorMessage {
shortMessage: string;
longMessage: string;
type?: 'fixable' | 'critical';
}

View file

@ -32,6 +32,15 @@ export function ChangeIndexPattern({
}) {
const [isPopoverOpen, setPopoverIsOpen] = useState(false);
const isMissingCurrent = !indexPatternRefs.some(({ id }) => id === indexPatternId);
// be careful to only add color with a value, otherwise it will fallbacks to "primary"
const colorProp = isMissingCurrent
? {
color: 'danger' as const,
}
: {};
const createTrigger = function () {
const { label, title, ...rest } = trigger;
return (
@ -39,6 +48,7 @@ export function ChangeIndexPattern({
title={title}
onClick={() => setPopoverIsOpen(!isPopoverOpen)}
fullWidth
{...colorProp}
{...rest}
>
{label}

View file

@ -146,8 +146,8 @@ export function IndexPatternDataPanel({
.map((l) => l.indexPatternId)
.concat(currentIndexPatternId)
)
.sort((a, b) => a.localeCompare(b))
.filter((id) => !!indexPatterns[id])
.sort((a, b) => a.localeCompare(b))
.map((id) => ({
id,
title: indexPatterns[id].title,
@ -171,7 +171,7 @@ export function IndexPatternDataPanel({
dateRange,
setState,
isFirstExistenceFetch: state.isFirstExistenceFetch,
currentIndexPatternTitle: indexPatterns[currentIndexPatternId].title,
currentIndexPatternTitle: indexPatterns[currentIndexPatternId]?.title || '',
showNoDataPopover,
indexPatterns: indexPatternList,
fetchJson: core.http.post,
@ -188,7 +188,7 @@ export function IndexPatternDataPanel({
]}
/>
{Object.keys(indexPatterns).length === 0 ? (
{Object.keys(indexPatterns).length === 0 && indexPatternRefs.length === 0 ? (
<EuiFlexGroup
gutterSize="m"
className="lnsInnerIndexPatternDataPanel"

View file

@ -416,6 +416,10 @@ export function getIndexPatternDatasource({
});
return messages.length ? messages : undefined;
},
checkIntegrity: (state) => {
const ids = Object.values(state.layers || {}).map(({ indexPatternId }) => indexPatternId);
return ids.filter((id) => !state.indexPatterns[id]);
},
};
return indexPatternDatasource;

View file

@ -7,6 +7,7 @@
import React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { DatasourceLayerPanelProps } from '../types';
import { IndexPatternPrivateState } from './types';
import { ChangeIndexPattern } from './change_indexpattern';
@ -20,13 +21,19 @@ export interface IndexPatternLayerPanelProps
export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatternLayerPanelProps) {
const layer = state.layers[layerId];
const indexPattern = state.indexPatterns[layer.indexPatternId];
const notFoundTitleLabel = i18n.translate('xpack.lens.layerPanel.missingIndexPattern', {
defaultMessage: 'Index pattern not found',
});
return (
<I18nProvider>
<ChangeIndexPattern
data-test-subj="indexPattern-switcher"
trigger={{
label: state.indexPatterns[layer.indexPatternId].title,
title: state.indexPatterns[layer.indexPatternId].title,
label: indexPattern?.title || notFoundTitleLabel,
title: indexPattern?.title || notFoundTitleLabel,
'data-test-subj': 'lns_layerIndexPatternLabel',
size: 's',
fontWeight: 'normal',

View file

@ -186,7 +186,11 @@ const sampleIndexPatterns = {
function mockIndexPatternsService() {
return ({
get: jest.fn(async (id: '1' | '2') => {
return { ...sampleIndexPatternsFromService[id], metaFields: [] };
const result = { ...sampleIndexPatternsFromService[id], metaFields: [] };
if (!result.fields) {
result.fields = [];
}
return result;
}),
getIdsWithTitle: jest.fn(async () => {
return [
@ -691,7 +695,7 @@ describe('loader', () => {
expect(setState).not.toHaveBeenCalled();
expect(storage.set).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(err);
expect(onError).toHaveBeenCalledWith(Error('Missing indexpatterns'));
});
});
@ -819,7 +823,7 @@ describe('loader', () => {
expect(setState).not.toHaveBeenCalled();
expect(storage.set).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(err);
expect(onError).toHaveBeenCalledWith(Error('Missing indexpatterns'));
});
});

View file

@ -22,6 +22,7 @@ import { DateRange, ExistingFields } from '../../common/types';
import { BASE_API_URL } from '../../common';
import {
IndexPatternsContract,
IndexPattern as IndexPatternInstance,
indexPatterns as indexPatternsUtils,
} from '../../../../../src/plugins/data/public';
import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/public';
@ -48,7 +49,17 @@ export async function loadIndexPatterns({
return cache;
}
const indexPatterns = await Promise.all(missingIds.map((id) => indexPatternsService.get(id)));
const allIndexPatterns = await Promise.allSettled(
missingIds.map((id) => indexPatternsService.get(id))
);
// ignore rejected indexpatterns here, they're already handled at the app level
const indexPatterns = allIndexPatterns
.filter(
(response): response is PromiseFulfilledResult<IndexPatternInstance> =>
response.status === 'fulfilled'
)
.map((response) => response.value);
const indexPatternsObject = indexPatterns.reduce(
(acc, indexPattern) => {
const newFields = indexPattern.fields
@ -201,13 +212,16 @@ export async function loadInitialState({
options?: InitializationOptions;
}): Promise<IndexPatternPrivateState> {
const { isFullEditor } = options ?? {};
const indexPatternRefs = await (isFullEditor ? loadIndexPatternRefs(indexPatternsService) : []);
// make it explicit or TS will infer never[] and break few lines down
const indexPatternRefs: IndexPatternRef[] = await (isFullEditor
? loadIndexPatternRefs(indexPatternsService)
: []);
const lastUsedIndexPatternId = getLastUsedIndexPatternId(storage, indexPatternRefs);
const state =
persistedState && references ? injectReferences(persistedState, references) : undefined;
const requiredPatterns = _.uniq(
const requiredPatterns: string[] = _.uniq(
state
? Object.values(state.layers)
.map((l) => l.indexPatternId)
@ -217,11 +231,26 @@ export async function loadInitialState({
// take out the undefined from the list
.filter(Boolean);
const currentIndexPatternId = initialContext?.indexPatternId ?? requiredPatterns[0];
const availableIndexPatterns = new Set(indexPatternRefs.map(({ id }: IndexPatternRef) => id));
// Priority list:
// * start with the indexPattern in context
// * then fallback to the required ones
// * then as last resort use a random one from the available list
const availableIndexPatternIds = [
initialContext?.indexPatternId,
...requiredPatterns,
indexPatternRefs[0]?.id,
].filter((id) => id != null && availableIndexPatterns.has(id));
const currentIndexPatternId = availableIndexPatternIds[0]!;
if (currentIndexPatternId) {
setLastUsedIndexPatternId(storage, currentIndexPatternId);
}
if (!requiredPatterns.includes(currentIndexPatternId)) {
requiredPatterns.push(currentIndexPatternId);
}
const indexPatterns = await loadIndexPatterns({
indexPatternsService,
cache: {},
@ -263,13 +292,17 @@ export async function changeIndexPattern({
storage: IStorageWrapper;
indexPatternsService: IndexPatternsService;
}) {
try {
const indexPatterns = await loadIndexPatterns({
indexPatternsService,
cache: state.indexPatterns,
patterns: [id],
});
const indexPatterns = await loadIndexPatterns({
indexPatternsService,
cache: state.indexPatterns,
patterns: [id],
});
if (indexPatterns[id] == null) {
return onError(Error('Missing indexpatterns'));
}
try {
setState((s) => ({
...s,
layers: isSingleEmptyLayer(state.layers)
@ -306,13 +339,16 @@ export async function changeLayerIndexPattern({
storage: IStorageWrapper;
indexPatternsService: IndexPatternsService;
}) {
try {
const indexPatterns = await loadIndexPatterns({
indexPatternsService,
cache: state.indexPatterns,
patterns: [indexPatternId],
});
const indexPatterns = await loadIndexPatterns({
indexPatternsService,
cache: state.indexPatterns,
patterns: [indexPatternId],
});
if (indexPatterns[indexPatternId] == null) {
return onError(Error('Missing indexpatterns'));
}
try {
setState((s) => ({
...s,
layers: {

View file

@ -30,7 +30,7 @@ function getExpressionForLayer(
uiSettings: IUiSettingsClient
): ExpressionAstExpression | null {
const { columns, columnOrder } = layer;
if (columnOrder.length === 0) {
if (columnOrder.length === 0 || !indexPattern) {
return null;
}

View file

@ -183,6 +183,13 @@ export class LensPlugin {
return ContextProvider;
};
const ensureDefaultIndexPattern = async () => {
const [, deps] = await core.getStartServices();
// make sure a default index pattern exists
// if not, the page will be redirected to management and visualize won't be rendered
await deps.data.indexPatterns.ensureDefaultIndexPattern();
};
core.application.register({
id: APP_ID,
title: NOT_INTERNATIONALIZED_PRODUCT_NAME,
@ -190,6 +197,7 @@ export class LensPlugin {
mount: async (params: AppMountParameters) => {
const { mountApp, stopReportManager } = await import('./async_services');
this.stopReportManager = stopReportManager;
await ensureDefaultIndexPattern();
return mountApp(core, params, {
createEditorFrame: this.createEditorFrame!,
attributeService: this.attributeService!,

View file

@ -224,6 +224,10 @@ export interface Datasource<T = unknown, P = unknown> {
* uniqueLabels of dimensions exposed for aria-labels of dragged dimensions
*/
uniqueLabels: (state: T) => Record<string, string>;
/**
* Check the internal state integrity and returns a list of missing references
*/
checkIntegrity: (state: T) => string[];
}
/**

View file

@ -23,3 +23,7 @@
justify-content: center;
overflow: auto;
}
.lnsSelectableErrorMessage {
user-select: text;
}

View file

@ -11804,7 +11804,6 @@
"xpack.lens.app.docLoadingError": "保存されたドキュメントの保存中にエラーが発生",
"xpack.lens.app.downloadButtonAriaLabel": "データを CSV ファイルとしてダウンロード",
"xpack.lens.app.downloadCSV": "CSV をダウンロード",
"xpack.lens.app.indexPatternLoadingError": "インデックスパターンの読み込み中にエラーが発生",
"xpack.lens.app.save": "保存",
"xpack.lens.app.saveAndReturn": "保存して戻る",
"xpack.lens.app.saveAndReturnButtonAriaLabel": "現在のLensビジュアライゼーションを保存し、前回使用していたアプリに戻る",

View file

@ -11960,7 +11960,6 @@
"xpack.lens.app.docLoadingError": "加载已保存文档时出错",
"xpack.lens.app.downloadButtonAriaLabel": "将数据下载为 CSV 文件",
"xpack.lens.app.downloadCSV": "下载为 CSV",
"xpack.lens.app.indexPatternLoadingError": "加载索引模式时出错",
"xpack.lens.app.save": "保存",
"xpack.lens.app.saveAndReturn": "保存并返回",
"xpack.lens.app.saveAndReturnButtonAriaLabel": "保存当前 Lens 可视化并返回到上一应用",