From f82a575a548d5187d748aba7f8ba1abfdbf604b6 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 1 Mar 2022 11:13:28 +0100 Subject: [PATCH] [Graph] Make graph app resilient to no fields or missing data views (#126441) * :bug: Fix broken scenarios with no fields or dataviews * Update x-pack/plugins/graph/public/state_management/persistence.ts * Update x-pack/plugins/graph/public/components/search_bar.tsx Co-authored-by: Matthias Wilhelm Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Matthias Wilhelm --- .../public/components/search_bar.test.tsx | 64 ++++++++++++++++++- .../graph/public/components/search_bar.tsx | 34 +++++++--- .../public/components/tooltip_wrapper.tsx | 34 ++++++++++ .../graph/public/state_management/mocks.ts | 9 ++- .../state_management/persistence.test.ts | 21 +++++- .../public/state_management/persistence.ts | 18 ++++-- 6 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/graph/public/components/tooltip_wrapper.tsx diff --git a/x-pack/plugins/graph/public/components/search_bar.test.tsx b/x-pack/plugins/graph/public/components/search_bar.test.tsx index 58a2996fd1e67..c16e661c85bf8 100644 --- a/x-pack/plugins/graph/public/components/search_bar.test.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.test.tsx @@ -6,7 +6,7 @@ */ import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { SearchBar, SearchBarProps } from './search_bar'; +import { SearchBar, SearchBarProps, SearchBarComponent, SearchBarStateProps } from './search_bar'; import React, { Component, ReactElement } from 'react'; import { CoreStart } from 'src/core/public'; import { act } from 'react-dom/test-utils'; @@ -26,8 +26,8 @@ jest.mock('../services/source_modal', () => ({ openSourceModal: jest.fn() })); const waitForIndexPatternFetch = () => new Promise((r) => setTimeout(r)); -function wrapSearchBarInContext(testProps: SearchBarProps) { - const services = { +function getServiceMocks() { + return { uiSettings: { get: (key: string) => { return 10; @@ -56,7 +56,10 @@ function wrapSearchBarInContext(testProps: SearchBarProps) { }, }, }; +} +function wrapSearchBarInContext(testProps: SearchBarProps) { + const services = getServiceMocks(); return ( @@ -120,6 +123,21 @@ describe('search_bar', () => { }); } + async function mountSearchBarWithExplicitContext(props: SearchBarProps & SearchBarStateProps) { + jest.clearAllMocks(); + const services = getServiceMocks(); + + await act(async () => { + instance = mountWithIntl( + + + + + + ); + }); + } + it('should render search bar and fetch index pattern', async () => { await mountSearchBar(); @@ -175,4 +193,44 @@ describe('search_bar', () => { expect(openSourceModal).toHaveBeenCalled(); }); + + it('should disable the graph button when no data view is configured', async () => { + const stateProps = { + submit: jest.fn(), + onIndexPatternSelected: jest.fn(), + currentDatasource: undefined, + selectedFields: [], + }; + await mountSearchBarWithExplicitContext({ + urlQuery: null, + ...defaultProps, + ...stateProps, + }); + + expect( + instance.find('[data-test-subj="graph-explore-button"]').first().prop('disabled') + ).toBeTruthy(); + }); + + it('should disable the graph button when no field is configured', async () => { + const stateProps = { + submit: jest.fn(), + onIndexPatternSelected: jest.fn(), + currentDatasource: { + type: 'indexpattern' as const, + id: '123', + title: 'test-index', + }, + selectedFields: [], + }; + await mountSearchBarWithExplicitContext({ + urlQuery: null, + ...defaultProps, + ...stateProps, + }); + + expect( + instance.find('[data-test-subj="graph-explore-button"]').first().prop('disabled') + ).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/graph/public/components/search_bar.tsx b/x-pack/plugins/graph/public/components/search_bar.tsx index 57e9831d5a656..0760fb4fd2c13 100644 --- a/x-pack/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.tsx @@ -10,7 +10,7 @@ import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { connect } from 'react-redux'; -import { IndexPatternSavedObject, IndexPatternProvider } from '../types'; +import { IndexPatternSavedObject, IndexPatternProvider, WorkspaceField } from '../types'; import { openSourceModal } from '../services/source_modal'; import { GraphState, @@ -18,6 +18,7 @@ import { requestDatasource, IndexpatternDatasource, submitSearch, + selectedFieldsSelector, } from '../state_management'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; @@ -28,6 +29,7 @@ import { Query, esKuery, } from '../../../../../src/plugins/data/public'; +import { TooltipWrapper } from './tooltip_wrapper'; export interface SearchBarProps { isLoading: boolean; @@ -44,6 +46,7 @@ export interface SearchBarProps { export interface SearchBarStateProps { currentDatasource?: IndexpatternDatasource; + selectedFields: WorkspaceField[]; onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => void; submit: (searchTerm: string) => void; } @@ -74,6 +77,7 @@ export function SearchBarComponent(props: SearchBarStateProps & SearchBarProps) currentIndexPattern, currentDatasource, indexPatternProvider, + selectedFields, submit, onIndexPatternSelected, confirmWipeWorkspace, @@ -170,14 +174,27 @@ export function SearchBarComponent(props: SearchBarStateProps & SearchBarProps) /> - - {i18n.translate('xpack.graph.bar.exploreLabel', { defaultMessage: 'Graph' })} - + + {i18n.translate('xpack.graph.bar.exploreLabel', { defaultMessage: 'Graph' })} + + @@ -190,6 +207,7 @@ export const SearchBar = connect( return { currentDatasource: datasource.current.type === 'indexpattern' ? datasource.current : undefined, + selectedFields: selectedFieldsSelector(state), }; }, (dispatch) => ({ diff --git a/x-pack/plugins/graph/public/components/tooltip_wrapper.tsx b/x-pack/plugins/graph/public/components/tooltip_wrapper.tsx new file mode 100644 index 0000000000000..5ab7800e05349 --- /dev/null +++ b/x-pack/plugins/graph/public/components/tooltip_wrapper.tsx @@ -0,0 +1,34 @@ +/* + * 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 { EuiToolTip, EuiToolTipProps } from '@elastic/eui'; + +export type TooltipWrapperProps = Partial> & { + tooltipContent: string; + /** When the condition is truthy, the tooltip will be shown */ + condition: boolean; +}; + +export const TooltipWrapper: React.FunctionComponent = ({ + children, + condition, + tooltipContent, + ...tooltipProps +}) => { + return ( + <> + {condition ? ( + + <>{children} + + ) : ( + children + )} + + ); +}; diff --git a/x-pack/plugins/graph/public/state_management/mocks.ts b/x-pack/plugins/graph/public/state_management/mocks.ts index 5003e550f87a1..906bcde9070fc 100644 --- a/x-pack/plugins/graph/public/state_management/mocks.ts +++ b/x-pack/plugins/graph/public/state_management/mocks.ts @@ -59,9 +59,12 @@ export function createMockGraphStore({ createWorkspace: jest.fn((index, advancedSettings) => workspaceMock), getWorkspace: jest.fn(() => workspaceMock), indexPatternProvider: { - get: jest.fn(() => - Promise.resolve({ id: '123', title: 'test-pattern' } as unknown as IndexPattern) - ), + get: jest.fn(async (id: string) => { + if (id === 'missing-dataview') { + throw Error('No data view with this id'); + } + return { id: '123', title: 'test-pattern' } as unknown as IndexPattern; + }), }, I18nContext: jest .fn() diff --git a/x-pack/plugins/graph/public/state_management/persistence.test.ts b/x-pack/plugins/graph/public/state_management/persistence.test.ts index 2ef68f2198070..9f23fd773d789 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.test.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.test.ts @@ -18,7 +18,11 @@ import { IndexpatternDatasource, datasourceSelector } from './datasource'; import { fieldsSelector } from './fields'; import { metaDataSelector, updateMetaData } from './meta_data'; import { templatesSelector } from './url_templates'; -import { migrateLegacyIndexPatternRef, appStateToSavedWorkspace } from '../services/persistence'; +import { + migrateLegacyIndexPatternRef, + appStateToSavedWorkspace, + lookupIndexPatternId, +} from '../services/persistence'; import { settingsSelector } from './advanced_settings'; import { openSaveModal } from '../services/save_modal'; @@ -96,6 +100,21 @@ describe('persistence sagas', () => { const resultingState = env.store.getState(); expect(datasourceSelector(resultingState).current.type).toEqual('none'); }); + + it('should not crash if the data view goes missing', async () => { + (lookupIndexPatternId as jest.Mock).mockReturnValueOnce('missing-dataview'); + env.store.dispatch( + loadSavedWorkspace({ + savedWorkspace: { + title: 'my workspace', + }, + } as LoadSavedWorkspacePayload) + ); + await waitForPromise(); + expect(env.mockedDeps.notifications.toasts.addDanger).toHaveBeenCalledWith( + 'Data view "missing-dataview" not found' + ); + }); }); describe('saving saga', () => { diff --git a/x-pack/plugins/graph/public/state_management/persistence.ts b/x-pack/plugins/graph/public/state_management/persistence.ts index 27a635d25eeaf..d1e038bbb2102 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.ts @@ -66,10 +66,20 @@ export const loadingSaga = ({ } const selectedIndexPatternId = lookupIndexPatternId(savedWorkspace); - const indexPattern = (yield call( - indexPatternProvider.get, - selectedIndexPatternId - )) as IndexPattern; + let indexPattern; + try { + indexPattern = (yield call(indexPatternProvider.get, selectedIndexPatternId)) as IndexPattern; + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.graph.loadWorkspace.missingDataViewErrorMessage', { + defaultMessage: 'Data view "{name}" not found', + values: { + name: selectedIndexPatternId, + }, + }) + ); + return; + } const initialSettings = settingsSelector((yield select()) as GraphState); const createdWorkspace = createWorkspace(indexPattern.title, initialSettings);