From 1c91b1c9c23275d2bcc74f93359d72af38065d16 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Wed, 8 Jul 2020 15:06:53 +0300 Subject: [PATCH] [Visualize] Add unit tests (#70410) * Reactify visualize app * Fix typescript failures after merging master * Make sure refresh button works * Subscribe filter manager fetches * Use redirect to landing page * Update savedSearch type * Add check for TSVB is loaded * Add unit tests for useSavedVisInstance effect * Fix comments * Fix uiState persistence on vis load * Remove extra div around TableListView * Update DTS selectors * Add error handling for embeddable * Add unit tests for createVisualizeAppState * Add unit tests for useChromeVisibility * Add filter_manager.mock * Add unit tests for useVisualizeAppState * Use app state stub * Add unit tests for useLinkedSearchUpdates * Add unit tests for useEditorUpdates * Remove extra argument from useEditorUpdates effect * Update comments, fix typos * Remove extra div wrapper * Apply design suggestions * Revert accidental config changes * Add unit tests for useEditorUpdates * Use visualize services mock * Add unit tests for getVisualizationInstance * Fix eslint warnings Co-authored-by: Elastic Machine --- .../filter_manager/filter_manager.mock.ts | 42 +++ src/plugins/data/public/query/mocks.ts | 5 +- src/plugins/visualizations/public/mocks.ts | 4 +- .../visualize/public/application/types.ts | 2 +- .../utils/create_visualize_app_state.test.ts | 134 +++++++ .../utils/get_visualization_instance.test.ts | 124 +++++++ .../public/application/utils/mocks.ts | 43 +++ .../public/application/utils/stubs.ts | 89 +++++ .../utils/use/use_chrome_visibility.test.ts | 55 +++ .../utils/use/use_editor_updates.test.ts | 327 ++++++++++++++++++ .../use/use_linked_search_updates.test.ts | 76 ++++ .../utils/use/use_saved_vis_instance.test.ts | 224 ++++++++++++ .../utils/use/use_visualize_app_state.test.ts | 210 +++++++++++ 13 files changed, 1331 insertions(+), 4 deletions(-) create mode 100644 src/plugins/data/public/query/filter_manager/filter_manager.mock.ts create mode 100644 src/plugins/visualize/public/application/utils/create_visualize_app_state.test.ts create mode 100644 src/plugins/visualize/public/application/utils/get_visualization_instance.test.ts create mode 100644 src/plugins/visualize/public/application/utils/mocks.ts create mode 100644 src/plugins/visualize/public/application/utils/stubs.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_chrome_visibility.test.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_editor_updates.test.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_linked_search_updates.test.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts diff --git a/src/plugins/data/public/query/filter_manager/filter_manager.mock.ts b/src/plugins/data/public/query/filter_manager/filter_manager.mock.ts new file mode 100644 index 0000000000000..c95a943be7713 --- /dev/null +++ b/src/plugins/data/public/query/filter_manager/filter_manager.mock.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from 'rxjs'; +import { FilterManager } from './filter_manager'; + +export const createFilterManagerMock = () => { + const filterManager = ({ + mergeIncomingFilters: jest.fn(), + handleStateUpdate: jest.fn(), + getFilters: jest.fn(), + getAppFilters: jest.fn(), + getGlobalFilters: jest.fn(), + getPartitionedFilters: jest.fn(), + getUpdates$: jest.fn(() => new Observable()), + getFetches$: jest.fn(() => new Observable()), + addFilters: jest.fn(), + setFilters: jest.fn(), + setGlobalFilters: jest.fn(), + setAppFilters: jest.fn(), + removeFilter: jest.fn(), + removeAll: jest.fn(), + } as unknown) as jest.Mocked; + + return filterManager; +}; diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index 0c19f71277bc5..41896107bb868 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -20,12 +20,13 @@ import { Observable } from 'rxjs'; import { QueryService, QuerySetup, QueryStart } from '.'; import { timefilterServiceMock } from './timefilter/timefilter_service.mock'; +import { createFilterManagerMock } from './filter_manager/filter_manager.mock'; type QueryServiceClientContract = PublicMethodsOf; const createSetupContractMock = () => { const setupContract: jest.Mocked = { - filterManager: jest.fn() as any, + filterManager: createFilterManagerMock(), timefilter: timefilterServiceMock.createSetupContract(), state$: new Observable(), }; @@ -36,7 +37,7 @@ const createSetupContractMock = () => { const createStartContractMock = () => { const startContract: jest.Mocked = { addToQueryLog: jest.fn(), - filterManager: jest.fn() as any, + filterManager: createFilterManagerMock(), savedQueries: jest.fn() as any, state$: new Observable(), timefilter: timefilterServiceMock.createStartContract(), diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 05644eddc5fca..e0ec4801b3caf 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -39,7 +39,9 @@ const createStartContract = (): VisualizationsStart => ({ get: jest.fn(), all: jest.fn(), getAliases: jest.fn(), - savedVisualizationsLoader: {} as any, + savedVisualizationsLoader: { + get: jest.fn(), + } as any, showNewVisModal: jest.fn(), createVis: jest.fn(), convertFromSerializedVis: jest.fn(), diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index a6adaf1f3c62b..02ae1cc155dd2 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -50,7 +50,7 @@ export type PureVisState = SavedVisState; export interface VisualizeAppState { filters: Filter[]; - uiState: PersistedState; + uiState: Record; vis: PureVisState; query: Query; savedQuery?: string; diff --git a/src/plugins/visualize/public/application/utils/create_visualize_app_state.test.ts b/src/plugins/visualize/public/application/utils/create_visualize_app_state.test.ts new file mode 100644 index 0000000000000..885eec8a68d2d --- /dev/null +++ b/src/plugins/visualize/public/application/utils/create_visualize_app_state.test.ts @@ -0,0 +1,134 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; +import { createVisualizeAppState } from './create_visualize_app_state'; +import { migrateAppState } from './migrate_app_state'; +import { visualizeAppStateStub } from './stubs'; + +const mockStartStateSync = jest.fn(); +const mockStopStateSync = jest.fn(); + +jest.mock('../../../../kibana_utils/public', () => ({ + createStateContainer: jest.fn(() => 'stateContainer'), + syncState: jest.fn(() => ({ + start: mockStartStateSync, + stop: mockStopStateSync, + })), +})); +jest.mock('./migrate_app_state', () => ({ + migrateAppState: jest.fn(() => 'migratedAppState'), +})); + +const { createStateContainer, syncState } = jest.requireMock('../../../../kibana_utils/public'); + +describe('createVisualizeAppState', () => { + const kbnUrlStateStorage = ({ + set: jest.fn(), + get: jest.fn(() => ({ linked: false })), + } as unknown) as IKbnUrlStateStorage; + + const { stateContainer, stopStateSync } = createVisualizeAppState({ + stateDefaults: visualizeAppStateStub, + kbnUrlStateStorage, + }); + const transitions = createStateContainer.mock.calls[0][1]; + + test('should initialize visualize app state', () => { + expect(kbnUrlStateStorage.get).toHaveBeenCalledWith('_a'); + expect(migrateAppState).toHaveBeenCalledWith({ + ...visualizeAppStateStub, + linked: false, + }); + expect(kbnUrlStateStorage.set).toHaveBeenCalledWith('_a', 'migratedAppState', { + replace: true, + }); + expect(createStateContainer).toHaveBeenCalled(); + expect(syncState).toHaveBeenCalled(); + expect(mockStartStateSync).toHaveBeenCalled(); + }); + + test('should return the stateContainer and stopStateSync', () => { + expect(stateContainer).toBe('stateContainer'); + stopStateSync(); + expect(stopStateSync).toHaveBeenCalledTimes(1); + }); + + describe('stateContainer transitions', () => { + test('set', () => { + const newQuery = { query: '', language: '' }; + expect(transitions.set(visualizeAppStateStub)('query', newQuery)).toEqual({ + ...visualizeAppStateStub, + query: newQuery, + }); + }); + + test('setVis', () => { + const newVis = { data: 'data' }; + expect(transitions.setVis(visualizeAppStateStub)(newVis)).toEqual({ + ...visualizeAppStateStub, + vis: { + ...visualizeAppStateStub.vis, + ...newVis, + }, + }); + }); + + test('unlinkSavedSearch', () => { + const params = { + query: { query: '', language: '' }, + parentFilters: [{ test: 'filter2' }], + }; + expect(transitions.unlinkSavedSearch(visualizeAppStateStub)(params)).toEqual({ + ...visualizeAppStateStub, + query: params.query, + filters: [...visualizeAppStateStub.filters, { test: 'filter2' }], + linked: false, + }); + }); + + test('updateVisState: should not include resctricted param types', () => { + const newVisState = { + a: 1, + _b: 2, + $c: 3, + d: () => {}, + }; + expect(transitions.updateVisState(visualizeAppStateStub)(newVisState)).toEqual({ + ...visualizeAppStateStub, + vis: { a: 1 }, + }); + }); + + test('updateSavedQuery: add savedQuery', () => { + const savedQueryId = '123test'; + expect(transitions.updateSavedQuery(visualizeAppStateStub)(savedQueryId)).toEqual({ + ...visualizeAppStateStub, + savedQuery: savedQueryId, + }); + }); + + test('updateSavedQuery: remove savedQuery from state', () => { + const savedQueryId = '123test'; + expect( + transitions.updateSavedQuery({ ...visualizeAppStateStub, savedQuery: savedQueryId })() + ).toEqual(visualizeAppStateStub); + }); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.test.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.test.ts new file mode 100644 index 0000000000000..31f0fc5f94479 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.test.ts @@ -0,0 +1,124 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSavedSearchesLoader } from '../../../../discover/public'; +import { getVisualizationInstance } from './get_visualization_instance'; +import { createVisualizeServicesMock } from './mocks'; +import { VisualizeServices } from '../types'; +import { BehaviorSubject } from 'rxjs'; + +const mockSavedSearchObj = {}; +const mockGetSavedSearch = jest.fn(() => mockSavedSearchObj); + +jest.mock('../../../../discover/public', () => ({ + createSavedSearchesLoader: jest.fn(() => ({ + get: mockGetSavedSearch, + })), +})); + +describe('getVisualizationInstance', () => { + const serializedVisMock = { + type: 'area', + }; + let savedVisMock: any; + let visMock: any; + let mockServices: jest.Mocked; + let subj: BehaviorSubject; + + beforeEach(() => { + mockServices = createVisualizeServicesMock(); + subj = new BehaviorSubject({}); + visMock = { + type: {}, + data: {}, + }; + savedVisMock = {}; + // @ts-expect-error + mockServices.savedVisualizations.get.mockImplementation(() => savedVisMock); + // @ts-expect-error + mockServices.visualizations.convertToSerializedVis.mockImplementation(() => serializedVisMock); + // @ts-expect-error + mockServices.visualizations.createVis.mockImplementation(() => visMock); + // @ts-expect-error + mockServices.createVisEmbeddableFromObject.mockImplementation(() => ({ + getOutput$: jest.fn(() => subj.asObservable()), + })); + }); + + test('should create new instances of savedVis, vis and embeddableHandler', async () => { + const opts = { + type: 'area', + indexPattern: 'my_index_pattern', + }; + const { savedVis, savedSearch, vis, embeddableHandler } = await getVisualizationInstance( + mockServices, + opts + ); + + expect(mockServices.savedVisualizations.get).toHaveBeenCalledWith(opts); + expect(savedVisMock.searchSourceFields).toEqual({ + index: opts.indexPattern, + }); + expect(mockServices.visualizations.convertToSerializedVis).toHaveBeenCalledWith(savedVisMock); + expect(mockServices.visualizations.createVis).toHaveBeenCalledWith( + serializedVisMock.type, + serializedVisMock + ); + expect(mockServices.createVisEmbeddableFromObject).toHaveBeenCalledWith(visMock, { + timeRange: undefined, + filters: undefined, + id: '', + }); + + expect(vis).toBe(visMock); + expect(savedVis).toBe(savedVisMock); + expect(embeddableHandler).toBeDefined(); + expect(savedSearch).toBeUndefined(); + }); + + test('should load existing vis by id and call vis type setup if exists', async () => { + const newVisObj = { data: {} }; + visMock.type.setup = jest.fn(() => newVisObj); + const { vis } = await getVisualizationInstance(mockServices, 'saved_vis_id'); + + expect(mockServices.savedVisualizations.get).toHaveBeenCalledWith('saved_vis_id'); + expect(savedVisMock.searchSourceFields).toBeUndefined(); + expect(visMock.type.setup).toHaveBeenCalledWith(visMock); + expect(vis).toBe(newVisObj); + }); + + test('should create saved search instance if vis based on saved search id', async () => { + visMock.data.savedSearchId = 'saved_search_id'; + const { savedSearch } = await getVisualizationInstance(mockServices, 'saved_vis_id'); + + expect(createSavedSearchesLoader).toHaveBeenCalled(); + expect(mockGetSavedSearch).toHaveBeenCalledWith(visMock.data.savedSearchId); + expect(savedSearch).toBe(mockSavedSearchObj); + }); + + test('should subscribe on embeddable handler updates and send toasts on errors', async () => { + await getVisualizationInstance(mockServices, 'saved_vis_id'); + + subj.next({ + error: 'error', + }); + + expect(mockServices.toastNotifications.addError).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/mocks.ts b/src/plugins/visualize/public/application/utils/mocks.ts new file mode 100644 index 0000000000000..09e7ba23875ca --- /dev/null +++ b/src/plugins/visualize/public/application/utils/mocks.ts @@ -0,0 +1,43 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { coreMock } from '../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { visualizationsPluginMock } from '../../../../visualizations/public/mocks'; +import { VisualizeServices } from '../types'; + +export const createVisualizeServicesMock = () => { + const coreStartMock = coreMock.createStart(); + const dataStartMock = dataPluginMock.createStartContract(); + const toastNotifications = coreStartMock.notifications.toasts; + const visualizations = visualizationsPluginMock.createStartContract(); + + return ({ + ...coreStartMock, + data: dataStartMock, + toastNotifications, + history: { + replace: jest.fn(), + location: { pathname: '' }, + }, + visualizations, + savedVisualizations: visualizations.savedVisualizationsLoader, + createVisEmbeddableFromObject: visualizations.__LEGACY.createVisEmbeddableFromObject, + } as unknown) as jest.Mocked; +}; diff --git a/src/plugins/visualize/public/application/utils/stubs.ts b/src/plugins/visualize/public/application/utils/stubs.ts new file mode 100644 index 0000000000000..1bbd738a739cf --- /dev/null +++ b/src/plugins/visualize/public/application/utils/stubs.ts @@ -0,0 +1,89 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { VisualizeAppState } from '../types'; + +export const visualizeAppStateStub: VisualizeAppState = { + uiState: { + vis: { + defaultColors: { + '0 - 2': 'rgb(165,0,38)', + '2 - 3': 'rgb(255,255,190)', + '3 - 4': 'rgb(0,104,55)', + }, + }, + }, + query: { query: '', language: 'kuery' }, + filters: [], + vis: { + title: '[eCommerce] Average Sold Quantity', + type: 'gauge', + aggs: [ + { + id: '1', + enabled: true, + // @ts-expect-error + type: 'avg', + schema: 'metric', + params: { field: 'total_quantity', customLabel: 'average items' }, + }, + ], + params: { + type: 'gauge', + addTooltip: true, + addLegend: true, + isDisplayWarning: false, + gauge: { + extendRange: true, + percentageMode: false, + gaugeType: 'Circle', + gaugeStyle: 'Full', + backStyle: 'Full', + orientation: 'vertical', + colorSchema: 'Green to Red', + gaugeColorMode: 'Labels', + colorsRange: [ + { from: 0, to: 2 }, + { from: 2, to: 3 }, + { from: 3, to: 4 }, + ], + invertColors: true, + labels: { show: true, color: 'black' }, + scale: { show: false, labels: false, color: '#333' }, + type: 'meter', + style: { + bgWidth: 0.9, + width: 0.9, + mask: false, + bgMask: false, + maskBars: 50, + bgFill: '#eee', + bgColor: false, + subText: 'per order', + fontSize: 60, + labelColor: true, + }, + minAngle: 0, + maxAngle: 6.283185307179586, + alignment: 'horizontal', + }, + }, + }, + linked: false, +}; diff --git a/src/plugins/visualize/public/application/utils/use/use_chrome_visibility.test.ts b/src/plugins/visualize/public/application/utils/use/use_chrome_visibility.test.ts new file mode 100644 index 0000000000000..904816db22278 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/use/use_chrome_visibility.test.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { chromeServiceMock } from '../../../../../../core/public/mocks'; +import { useChromeVisibility } from './use_chrome_visibility'; + +describe('useChromeVisibility', () => { + const chromeMock = chromeServiceMock.createStartContract(); + + test('should set up a subscription for chrome visibility', () => { + const { result } = renderHook(() => useChromeVisibility(chromeMock)); + + expect(chromeMock.getIsVisible$).toHaveBeenCalled(); + expect(result.current).toEqual(false); + }); + + test('should change chrome visibility to true if change was emitted', () => { + const { result } = renderHook(() => useChromeVisibility(chromeMock)); + const behaviorSubj = chromeMock.getIsVisible$.mock.results[0].value; + act(() => { + behaviorSubj.next(true); + }); + + expect(result.current).toEqual(true); + }); + + test('should destroy a subscription', () => { + const { unmount } = renderHook(() => useChromeVisibility(chromeMock)); + const behaviorSubj = chromeMock.getIsVisible$.mock.results[0].value; + const subscription = behaviorSubj.observers[0]; + subscription.unsubscribe = jest.fn(); + + unmount(); + + expect(subscription.unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/use/use_editor_updates.test.ts b/src/plugins/visualize/public/application/utils/use/use_editor_updates.test.ts new file mode 100644 index 0000000000000..3546ee7b321bb --- /dev/null +++ b/src/plugins/visualize/public/application/utils/use/use_editor_updates.test.ts @@ -0,0 +1,327 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { EventEmitter } from 'events'; + +import { useEditorUpdates } from './use_editor_updates'; +import { + VisualizeServices, + VisualizeAppStateContainer, + SavedVisInstance, + IEditorController, +} from '../../types'; +import { visualizeAppStateStub } from '../stubs'; +import { createVisualizeServicesMock } from '../mocks'; + +describe('useEditorUpdates', () => { + const eventEmitter = new EventEmitter(); + const setHasUnsavedChangesMock = jest.fn(); + let mockServices: jest.Mocked; + + beforeEach(() => { + mockServices = createVisualizeServicesMock(); + // @ts-expect-error + mockServices.visualizations.convertFromSerializedVis.mockImplementation(() => ({ + visState: visualizeAppStateStub.vis, + })); + }); + + test('should not create any subscriptions if app state container is not ready', () => { + const { result } = renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + null, + undefined, + undefined + ) + ); + + expect(result.current).toEqual({ + isEmbeddableRendered: false, + currentAppState: undefined, + }); + }); + + let unsubscribeStateUpdatesMock: jest.Mock; + let appState: VisualizeAppStateContainer; + let savedVisInstance: SavedVisInstance; + let visEditorController: IEditorController; + let timeRange: any; + let mockFilters: any; + + beforeEach(() => { + unsubscribeStateUpdatesMock = jest.fn(); + appState = ({ + getState: jest.fn(() => visualizeAppStateStub), + subscribe: jest.fn(() => unsubscribeStateUpdatesMock), + transitions: { + set: jest.fn(), + }, + } as unknown) as VisualizeAppStateContainer; + savedVisInstance = ({ + vis: { + uiState: { + on: jest.fn(), + off: jest.fn(), + setSilent: jest.fn(), + getChanges: jest.fn(() => visualizeAppStateStub.uiState), + }, + data: {}, + serialize: jest.fn(), + title: visualizeAppStateStub.vis.title, + setState: jest.fn(), + }, + embeddableHandler: { + updateInput: jest.fn(), + reload: jest.fn(), + }, + savedVis: {}, + } as unknown) as SavedVisInstance; + visEditorController = { + render: jest.fn(), + destroy: jest.fn(), + }; + timeRange = { + from: 'now-15m', + to: 'now', + }; + mockFilters = ['mockFilters']; + // @ts-expect-error + mockServices.data.query.timefilter.timefilter.getTime.mockImplementation(() => timeRange); + // @ts-expect-error + mockServices.data.query.filterManager.getFilters.mockImplementation(() => mockFilters); + }); + + test('should set up current app state and render the editor', () => { + const { result } = renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + appState, + savedVisInstance, + visEditorController + ) + ); + + expect(result.current).toEqual({ + isEmbeddableRendered: false, + currentAppState: visualizeAppStateStub, + }); + expect(savedVisInstance.vis.uiState.setSilent).toHaveBeenCalledWith( + visualizeAppStateStub.uiState + ); + expect(visEditorController.render).toHaveBeenCalledWith({ + core: mockServices, + data: mockServices.data, + uiState: savedVisInstance.vis.uiState, + timeRange, + filters: mockFilters, + query: visualizeAppStateStub.query, + linked: false, + savedSearch: undefined, + }); + }); + + test('should update embeddable handler in embeded mode', () => { + renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + appState, + savedVisInstance, + undefined + ) + ); + + expect(savedVisInstance.embeddableHandler.updateInput).toHaveBeenCalledWith({ + timeRange, + filters: mockFilters, + query: visualizeAppStateStub.query, + }); + }); + + test('should update isEmbeddableRendered value when embedabble is rendered', () => { + const { result } = renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + appState, + savedVisInstance, + undefined + ) + ); + + act(() => { + eventEmitter.emit('embeddableRendered'); + }); + + expect(result.current.isEmbeddableRendered).toBe(true); + }); + + test('should destroy subscriptions on unmount', () => { + const { unmount } = renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + appState, + savedVisInstance, + undefined + ) + ); + + unmount(); + + expect(unsubscribeStateUpdatesMock).toHaveBeenCalledTimes(1); + expect(savedVisInstance.vis.uiState.off).toHaveBeenCalledTimes(1); + }); + + describe('subscribe on app state updates', () => { + test('should subscribe on appState updates', () => { + const { result } = renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + appState, + savedVisInstance, + undefined + ) + ); + // @ts-expect-error + const listener = appState.subscribe.mock.calls[0][0]; + + act(() => { + listener(visualizeAppStateStub); + }); + + expect(result.current.currentAppState).toEqual(visualizeAppStateStub); + expect(setHasUnsavedChangesMock).toHaveBeenCalledWith(true); + expect(savedVisInstance.embeddableHandler.updateInput).toHaveBeenCalledTimes(2); + }); + + test('should update vis state and reload the editor if changes come from url', () => { + const { result } = renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + appState, + savedVisInstance, + undefined + ) + ); + // @ts-expect-error + const listener = appState.subscribe.mock.calls[0][0]; + const newAppState = { + ...visualizeAppStateStub, + vis: { + ...visualizeAppStateStub.vis, + title: 'New title', + }, + }; + const { aggs, ...visState } = newAppState.vis; + const updateEditorSpy = jest.fn(); + + eventEmitter.on('updateEditor', updateEditorSpy); + + act(() => { + listener(newAppState); + }); + + expect(result.current.currentAppState).toEqual(newAppState); + expect(savedVisInstance.vis.setState).toHaveBeenCalledWith({ + ...visState, + data: { aggs }, + }); + expect(savedVisInstance.embeddableHandler.reload).toHaveBeenCalled(); + expect(updateEditorSpy).toHaveBeenCalled(); + }); + + describe('handle linked search changes', () => { + test('should update saved search id in saved instance', () => { + // @ts-expect-error + savedVisInstance.savedSearch = { + id: 'saved_search_id', + }; + + renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + appState, + savedVisInstance, + undefined + ) + ); + // @ts-expect-error + const listener = appState.subscribe.mock.calls[0][0]; + + act(() => { + listener({ + ...visualizeAppStateStub, + linked: true, + }); + }); + + expect(savedVisInstance.savedVis.savedSearchId).toEqual('saved_search_id'); + expect(savedVisInstance.vis.data.savedSearchId).toEqual('saved_search_id'); + }); + + test('should remove saved search id from vis instance', () => { + // @ts-expect-error + savedVisInstance.savedVis = { + savedSearchId: 'saved_search_id', + }; + // @ts-expect-error + savedVisInstance.savedSearch = { + id: 'saved_search_id', + }; + savedVisInstance.vis.data.savedSearchId = 'saved_search_id'; + + renderHook(() => + useEditorUpdates( + mockServices, + eventEmitter, + setHasUnsavedChangesMock, + appState, + savedVisInstance, + undefined + ) + ); + // @ts-expect-error + const listener = appState.subscribe.mock.calls[0][0]; + + act(() => { + listener(visualizeAppStateStub); + }); + + expect(savedVisInstance.savedVis.savedSearchId).toBeUndefined(); + expect(savedVisInstance.vis.data.savedSearchId).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/use/use_linked_search_updates.test.ts b/src/plugins/visualize/public/application/utils/use/use_linked_search_updates.test.ts new file mode 100644 index 0000000000000..4c9ebbc1d9abd --- /dev/null +++ b/src/plugins/visualize/public/application/utils/use/use_linked_search_updates.test.ts @@ -0,0 +1,76 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { EventEmitter } from 'events'; + +import { useLinkedSearchUpdates } from './use_linked_search_updates'; +import { VisualizeServices, SavedVisInstance, VisualizeAppStateContainer } from '../../types'; +import { createVisualizeServicesMock } from '../mocks'; + +describe('useLinkedSearchUpdates', () => { + let mockServices: jest.Mocked; + const eventEmitter = new EventEmitter(); + const savedVisInstance = ({ + vis: { + data: { + searchSource: { setField: jest.fn(), setParent: jest.fn() }, + }, + }, + savedVis: {}, + embeddableHandler: {}, + } as unknown) as SavedVisInstance; + + beforeEach(() => { + mockServices = createVisualizeServicesMock(); + }); + + it('should not subscribe on unlinkFromSavedSearch event if appState or savedSearch are not defined', () => { + renderHook(() => useLinkedSearchUpdates(mockServices, eventEmitter, null, savedVisInstance)); + + expect(mockServices.toastNotifications.addSuccess).not.toHaveBeenCalled(); + }); + + it('should subscribe on unlinkFromSavedSearch event if vis is based on saved search', () => { + const mockAppState = ({ + transitions: { + unlinkSavedSearch: jest.fn(), + }, + } as unknown) as VisualizeAppStateContainer; + savedVisInstance.savedSearch = ({ + searchSource: { + getParent: jest.fn(), + getField: jest.fn(), + getOwnField: jest.fn(), + }, + title: 'savedSearch', + } as unknown) as SavedVisInstance['savedSearch']; + + renderHook(() => + useLinkedSearchUpdates(mockServices, eventEmitter, mockAppState, savedVisInstance) + ); + + eventEmitter.emit('unlinkFromSavedSearch'); + + expect(savedVisInstance.savedSearch?.searchSource?.getParent).toHaveBeenCalled(); + expect(savedVisInstance.savedSearch?.searchSource?.getField).toHaveBeenCalledWith('index'); + expect(mockAppState.transitions.unlinkSavedSearch).toHaveBeenCalled(); + expect(mockServices.toastNotifications.addSuccess).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts new file mode 100644 index 0000000000000..a6b6d8ca0e837 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts @@ -0,0 +1,224 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { EventEmitter } from 'events'; + +import { coreMock } from '../../../../../../core/public/mocks'; +import { useSavedVisInstance } from './use_saved_vis_instance'; +import { redirectWhenMissing } from '../../../../../kibana_utils/public'; +import { getEditBreadcrumbs, getCreateBreadcrumbs } from '../breadcrumbs'; +import { VisualizeServices } from '../../types'; +import { VisualizeConstants } from '../../visualize_constants'; + +const mockDefaultEditorControllerDestroy = jest.fn(); +const mockEmbeddableHandlerDestroy = jest.fn(); +const mockEmbeddableHandlerRender = jest.fn(); +const mockSavedVisDestroy = jest.fn(); +const savedVisId = '9ca7aa90-b892-11e8-a6d9-e546fe2bba5f'; +const mockSavedVisInstance = { + embeddableHandler: { + destroy: mockEmbeddableHandlerDestroy, + render: mockEmbeddableHandlerRender, + }, + savedVis: { + id: savedVisId, + title: 'Test Vis', + destroy: mockSavedVisDestroy, + }, + vis: { + type: {}, + }, +}; + +jest.mock('../get_visualization_instance', () => ({ + getVisualizationInstance: jest.fn(() => mockSavedVisInstance), +})); +jest.mock('../breadcrumbs', () => ({ + getEditBreadcrumbs: jest.fn((text) => text), + getCreateBreadcrumbs: jest.fn((text) => text), +})); +jest.mock('../../../../../vis_default_editor/public', () => ({ + DefaultEditorController: jest.fn(() => ({ destroy: mockDefaultEditorControllerDestroy })), +})); +jest.mock('../../../../../kibana_utils/public'); + +const mockGetVisualizationInstance = jest.requireMock('../get_visualization_instance') + .getVisualizationInstance; + +describe('useSavedVisInstance', () => { + const coreStartMock = coreMock.createStart(); + const toastNotifications = coreStartMock.notifications.toasts; + let mockServices: VisualizeServices; + const eventEmitter = new EventEmitter(); + + beforeEach(() => { + mockServices = ({ + ...coreStartMock, + toastNotifications, + history: { + location: { + pathname: VisualizeConstants.EDIT_PATH, + }, + replace: () => {}, + }, + visualizations: { + all: jest.fn(() => [ + { + name: 'area', + requiresSearch: true, + options: { + showIndexSelection: true, + }, + }, + { name: 'gauge' }, + ]), + }, + } as unknown) as VisualizeServices; + + mockDefaultEditorControllerDestroy.mockClear(); + mockEmbeddableHandlerDestroy.mockClear(); + mockEmbeddableHandlerRender.mockClear(); + mockSavedVisDestroy.mockClear(); + toastNotifications.addWarning.mockClear(); + mockGetVisualizationInstance.mockClear(); + }); + + test('should not load instance until chrome is defined', () => { + const { result } = renderHook(() => + useSavedVisInstance(mockServices, eventEmitter, undefined, undefined) + ); + expect(mockGetVisualizationInstance).not.toHaveBeenCalled(); + expect(result.current.visEditorController).toBeUndefined(); + expect(result.current.savedVisInstance).toBeUndefined(); + expect(result.current.visEditorRef).toBeDefined(); + }); + + describe('edit saved visualization route', () => { + test('should load instance and initiate an editor if chrome is set up', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSavedVisInstance(mockServices, eventEmitter, true, savedVisId) + ); + + expect(mockGetVisualizationInstance).toHaveBeenCalledWith(mockServices, savedVisId); + expect(mockGetVisualizationInstance.mock.calls.length).toBe(1); + + await waitForNextUpdate(); + expect(mockServices.chrome.setBreadcrumbs).toHaveBeenCalledWith('Test Vis'); + expect(getEditBreadcrumbs).toHaveBeenCalledWith('Test Vis'); + expect(getCreateBreadcrumbs).not.toHaveBeenCalled(); + expect(mockEmbeddableHandlerRender).not.toHaveBeenCalled(); + expect(result.current.visEditorController).toBeDefined(); + expect(result.current.savedVisInstance).toBeDefined(); + }); + + test('should destroy the editor and the savedVis on unmount if chrome exists', async () => { + const { unmount, waitForNextUpdate } = renderHook(() => + useSavedVisInstance(mockServices, eventEmitter, true, savedVisId) + ); + + await waitForNextUpdate(); + unmount(); + + expect(mockDefaultEditorControllerDestroy.mock.calls.length).toBe(1); + expect(mockEmbeddableHandlerDestroy).not.toHaveBeenCalled(); + expect(mockSavedVisDestroy.mock.calls.length).toBe(1); + }); + }); + + describe('create new visualization route', () => { + beforeEach(() => { + mockServices.history.location = { + ...mockServices.history.location, + pathname: VisualizeConstants.CREATE_PATH, + search: '?type=area&indexPattern=1a2b3c4d', + }; + delete mockSavedVisInstance.savedVis.id; + }); + + test('should create new visualization based on search params', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSavedVisInstance(mockServices, eventEmitter, true, undefined) + ); + + expect(mockGetVisualizationInstance).toHaveBeenCalledWith(mockServices, { + indexPattern: '1a2b3c4d', + type: 'area', + }); + + await waitForNextUpdate(); + + expect(getCreateBreadcrumbs).toHaveBeenCalled(); + expect(mockEmbeddableHandlerRender).not.toHaveBeenCalled(); + expect(result.current.visEditorController).toBeDefined(); + expect(result.current.savedVisInstance).toBeDefined(); + }); + + test('should throw error if vis type is invalid', async () => { + mockServices.history.location = { + ...mockServices.history.location, + search: '?type=myVisType&indexPattern=1a2b3c4d', + }; + + renderHook(() => useSavedVisInstance(mockServices, eventEmitter, true, undefined)); + + expect(mockGetVisualizationInstance).not.toHaveBeenCalled(); + expect(redirectWhenMissing).toHaveBeenCalled(); + expect(toastNotifications.addWarning).toHaveBeenCalled(); + }); + + test("should throw error if index pattern or saved search id doesn't exist in search params", async () => { + mockServices.history.location = { + ...mockServices.history.location, + search: '?type=area', + }; + + renderHook(() => useSavedVisInstance(mockServices, eventEmitter, true, undefined)); + + expect(mockGetVisualizationInstance).not.toHaveBeenCalled(); + expect(redirectWhenMissing).toHaveBeenCalled(); + expect(toastNotifications.addWarning).toHaveBeenCalled(); + }); + }); + + describe('embeded mode', () => { + test('should create new visualization based on search params', async () => { + const { result, unmount, waitForNextUpdate } = renderHook(() => + useSavedVisInstance(mockServices, eventEmitter, false, savedVisId) + ); + + // mock editor ref + // @ts-expect-error + result.current.visEditorRef.current = 'div'; + + expect(mockGetVisualizationInstance).toHaveBeenCalledWith(mockServices, savedVisId); + + await waitForNextUpdate(); + + expect(mockEmbeddableHandlerRender).toHaveBeenCalled(); + expect(result.current.visEditorController).toBeUndefined(); + expect(result.current.savedVisInstance).toBeDefined(); + + unmount(); + expect(mockDefaultEditorControllerDestroy).not.toHaveBeenCalled(); + expect(mockEmbeddableHandlerDestroy.mock.calls.length).toBe(1); + expect(mockSavedVisDestroy.mock.calls.length).toBe(1); + }); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts new file mode 100644 index 0000000000000..e885067c58184 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts @@ -0,0 +1,210 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { EventEmitter } from 'events'; +import { Observable } from 'rxjs'; + +import { useVisualizeAppState } from './use_visualize_app_state'; +import { VisualizeServices, SavedVisInstance } from '../../types'; +import { visualizeAppStateStub } from '../stubs'; +import { VisualizeConstants } from '../../visualize_constants'; +import { createVisualizeServicesMock } from '../mocks'; + +jest.mock('../utils'); +jest.mock('../create_visualize_app_state'); +jest.mock('../../../../../data/public'); + +describe('useVisualizeAppState', () => { + const { visStateToEditorState } = jest.requireMock('../utils'); + const { createVisualizeAppState } = jest.requireMock('../create_visualize_app_state'); + const { connectToQueryState } = jest.requireMock('../../../../../data/public'); + const stopStateSyncMock = jest.fn(); + const stateContainerGetStateMock = jest.fn(() => visualizeAppStateStub); + const stopSyncingAppFiltersMock = jest.fn(); + const stateContainer = { + getState: stateContainerGetStateMock, + state$: new Observable(), + transitions: { + updateVisState: jest.fn(), + set: jest.fn(), + }, + }; + + visStateToEditorState.mockImplementation(() => visualizeAppStateStub); + createVisualizeAppState.mockImplementation(() => ({ + stateContainer, + stopStateSync: stopStateSyncMock, + })); + connectToQueryState.mockImplementation(() => stopSyncingAppFiltersMock); + + const eventEmitter = new EventEmitter(); + const savedVisInstance = ({ + vis: { + setState: jest.fn().mockResolvedValue({}), + }, + savedVis: {}, + embeddableHandler: {}, + } as unknown) as SavedVisInstance; + let mockServices: jest.Mocked; + + beforeEach(() => { + mockServices = createVisualizeServicesMock(); + + stopStateSyncMock.mockClear(); + stopSyncingAppFiltersMock.mockClear(); + visStateToEditorState.mockClear(); + }); + + it("should not create appState if vis instance isn't ready", () => { + const { result } = renderHook(() => useVisualizeAppState(mockServices, eventEmitter)); + + expect(result.current).toEqual({ + appState: null, + hasUnappliedChanges: false, + }); + }); + + it('should create appState and connect it to query search params', () => { + const { result } = renderHook(() => + useVisualizeAppState(mockServices, eventEmitter, savedVisInstance) + ); + + expect(visStateToEditorState).toHaveBeenCalledWith(savedVisInstance, mockServices); + expect(createVisualizeAppState).toHaveBeenCalledWith({ + stateDefaults: visualizeAppStateStub, + kbnUrlStateStorage: undefined, + }); + expect(mockServices.data.query.filterManager.setAppFilters).toHaveBeenCalledWith( + visualizeAppStateStub.filters + ); + expect(connectToQueryState).toHaveBeenCalledWith(mockServices.data.query, expect.any(Object), { + filters: 'appState', + }); + expect(result.current).toEqual({ + appState: stateContainer, + hasUnappliedChanges: false, + }); + }); + + it('should stop state and app filters syncing with query on destroy', () => { + const { unmount } = renderHook(() => + useVisualizeAppState(mockServices, eventEmitter, savedVisInstance) + ); + + unmount(); + + expect(stopStateSyncMock).toBeCalledTimes(1); + expect(stopSyncingAppFiltersMock).toBeCalledTimes(1); + }); + + it('should be subscribed on dirtyStateChange event from an editor', () => { + const { result } = renderHook(() => + useVisualizeAppState(mockServices, eventEmitter, savedVisInstance) + ); + + act(() => { + eventEmitter.emit('dirtyStateChange', { isDirty: true }); + }); + + expect(result.current.hasUnappliedChanges).toEqual(true); + expect(stateContainer.transitions.updateVisState).not.toHaveBeenCalled(); + expect(visStateToEditorState).toHaveBeenCalledTimes(1); + + act(() => { + eventEmitter.emit('dirtyStateChange', { isDirty: false }); + }); + + expect(result.current.hasUnappliedChanges).toEqual(false); + expect(stateContainer.transitions.updateVisState).toHaveBeenCalledWith( + visualizeAppStateStub.vis + ); + expect(visStateToEditorState).toHaveBeenCalledTimes(2); + }); + + describe('update vis state if the url params are not equal with the saved object vis state', () => { + const newAgg = { + id: '2', + enabled: true, + type: 'terms', + schema: 'group', + params: { + field: 'total_quantity', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: '', + }, + }; + const state = { + ...visualizeAppStateStub, + vis: { + ...visualizeAppStateStub.vis, + aggs: [...visualizeAppStateStub.vis.aggs, newAgg], + }, + }; + + it('should successfully update vis state and set up app state container', async () => { + // @ts-expect-error + stateContainerGetStateMock.mockImplementation(() => state); + const { result, waitForNextUpdate } = renderHook(() => + useVisualizeAppState(mockServices, eventEmitter, savedVisInstance) + ); + + await waitForNextUpdate(); + + const { aggs, ...visState } = stateContainer.getState().vis; + const expectedNewVisState = { + ...visState, + data: { aggs: state.vis.aggs }, + }; + + expect(savedVisInstance.vis.setState).toHaveBeenCalledWith(expectedNewVisState); + expect(result.current).toEqual({ + appState: stateContainer, + hasUnappliedChanges: false, + }); + }); + + it(`should add warning toast and redirect to the landing page + if setting new vis state was not successful, e.x. invalid query params`, async () => { + // @ts-expect-error + stateContainerGetStateMock.mockImplementation(() => state); + // @ts-expect-error + savedVisInstance.vis.setState.mockRejectedValue({ + message: 'error', + }); + + renderHook(() => useVisualizeAppState(mockServices, eventEmitter, savedVisInstance)); + + await new Promise((res) => { + setTimeout(() => res()); + }); + + expect(mockServices.toastNotifications.addWarning).toHaveBeenCalled(); + expect(mockServices.history.replace).toHaveBeenCalledWith( + `${VisualizeConstants.LANDING_PAGE_PATH}?notFound=visualization` + ); + }); + }); +});