/* * 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 { Observable } from 'rxjs'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { App } from './app'; import { LensAppProps, LensAppServices } from './types'; import { EditorFrameInstance, EditorFrameProps } from '../types'; import { Document } from '../persistence'; import { DOC_TYPE } from '../../common'; import { mount } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; import { SavedObjectSaveModal, checkForDuplicateTitle, } from '../../../../../src/plugins/saved_objects/public'; import { createMemoryHistory } from 'history'; import { DataPublicPluginStart, esFilters, FilterManager, IFieldType, IIndexPattern, UI_SETTINGS, } from '../../../../../src/plugins/data/public'; import { navigationPluginMock } from '../../../../../src/plugins/navigation/public/mocks'; import { TopNavMenuData } from '../../../../../src/plugins/navigation/public'; import { coreMock } from 'src/core/public/mocks'; import { LensByValueInput, LensSavedObjectAttributes, LensByReferenceInput, } from '../editor_frame_service/embeddable/embeddable'; import { SavedObjectReference } from '../../../../../src/core/types'; import { mockAttributeService, createEmbeddableStateTransferMock, } from '../../../../../src/plugins/embeddable/public/mocks'; import { LensAttributeService } from '../lens_attribute_service'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { EmbeddableStateTransfer } from '../../../../../src/plugins/embeddable/public'; import { NativeRenderer } from '../native_renderer'; import moment from 'moment'; jest.mock('../editor_frame_service/editor_frame/expression_helpers'); jest.mock('src/core/public'); jest.mock('../../../../../src/plugins/saved_objects/public', () => { // eslint-disable-next-line @typescript-eslint/no-shadow const { SavedObjectSaveModal, SavedObjectSaveModalOrigin } = jest.requireActual( '../../../../../src/plugins/saved_objects/public' ); return { SavedObjectSaveModal, SavedObjectSaveModalOrigin, checkForDuplicateTitle: jest.fn(), }; }); const navigationStartMock = navigationPluginMock.createStartContract(); jest.spyOn(navigationStartMock.ui.TopNavMenu.prototype, 'constructor').mockImplementation(() => { return
; }); const { TopNavMenu } = navigationStartMock.ui; function createMockFrame(): jest.Mocked { return { mount: jest.fn(async (el, props) => {}), unmount: jest.fn(() => {}), }; } function createMockSearchService() { let sessionIdCounter = 1; return { session: { start: jest.fn(() => `sessionId-${sessionIdCounter++}`), clear: jest.fn(), getSessionId: jest.fn(() => `sessionId-${sessionIdCounter}`), }, }; } function createMockFilterManager() { const unsubscribe = jest.fn(); let subscriber: () => void; let filters: unknown = []; return { getUpdates$: () => ({ subscribe: ({ next }: { next: () => void }) => { subscriber = next; return unsubscribe; }, }), setFilters: jest.fn((newFilters: unknown[]) => { filters = newFilters; if (subscriber) subscriber(); }), setAppFilters: jest.fn((newFilters: unknown[]) => { filters = newFilters; if (subscriber) subscriber(); }), getFilters: () => filters, getGlobalFilters: () => { // @ts-ignore return filters.filter(esFilters.isFilterPinned); }, removeAll: () => { filters = []; subscriber(); }, }; } function createMockQueryString() { return { getQuery: jest.fn(() => ({ query: '', language: 'kuery' })), setQuery: jest.fn(), getDefaultQuery: jest.fn(() => ({ query: '', language: 'kuery' })), }; } function createMockTimefilter() { const unsubscribe = jest.fn(); let timeFilter = { from: 'now-7d', to: 'now' }; let subscriber: () => void; return { getTime: jest.fn(() => timeFilter), setTime: jest.fn((newTimeFilter) => { timeFilter = newTimeFilter; if (subscriber) { subscriber(); } }), getTimeUpdate$: () => ({ subscribe: ({ next }: { next: () => void }) => { subscriber = next; return unsubscribe; }, }), calculateBounds: jest.fn(() => ({ min: moment('2021-01-10T04:00:00.000Z'), max: moment('2021-01-10T08:00:00.000Z'), })), getBounds: jest.fn(() => timeFilter), getRefreshInterval: () => {}, getRefreshIntervalDefaults: () => {}, getAutoRefreshFetch$: () => new Observable(), }; } describe('Lens App', () => { let core: ReturnType; let defaultDoc: Document; let defaultSavedObjectId: string; const navMenuItems = { expectedSaveButton: { emphasize: true, testId: 'lnsApp_saveButton' }, expectedSaveAsButton: { emphasize: false, testId: 'lnsApp_saveButton' }, expectedSaveAndReturnButton: { emphasize: true, testId: 'lnsApp_saveAndReturnButton' }, }; function makeAttributeService(): LensAttributeService { const attributeServiceMock = mockAttributeService< LensSavedObjectAttributes, LensByValueInput, LensByReferenceInput >( DOC_TYPE, { saveMethod: jest.fn(), unwrapMethod: jest.fn(), checkForDuplicateTitle: jest.fn(), }, core ); attributeServiceMock.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc); attributeServiceMock.wrapAttributes = jest .fn() .mockResolvedValue({ savedObjectId: defaultSavedObjectId }); return attributeServiceMock; } function makeDefaultProps(): jest.Mocked { return { editorFrame: createMockFrame(), history: createMemoryHistory(), redirectTo: jest.fn(), redirectToOrigin: jest.fn(), onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), }; } function makeDefaultServices(): jest.Mocked { return { http: core.http, chrome: core.chrome, overlays: core.overlays, uiSettings: core.uiSettings, navigation: navigationStartMock, notifications: core.notifications, attributeService: makeAttributeService(), savedObjectsClient: core.savedObjects.client, dashboardFeatureFlag: { allowByValueEmbeddables: false }, stateTransfer: createEmbeddableStateTransferMock() as EmbeddableStateTransfer, getOriginatingAppName: jest.fn(() => 'defaultOriginatingApp'), application: { ...core.application, capabilities: { ...core.application.capabilities, visualize: { save: true, saveQuery: true, show: true }, }, getUrlForApp: jest.fn((appId: string) => `/testbasepath/app/${appId}#/`), }, data: ({ query: { filterManager: createMockFilterManager(), timefilter: { timefilter: createMockTimefilter(), }, queryString: createMockQueryString(), state$: new Observable(), }, indexPatterns: { get: jest.fn((id) => { return new Promise((resolve) => resolve({ id })); }), }, search: createMockSearchService(), nowProvider: { get: jest.fn(), }, } as unknown) as DataPublicPluginStart, storage: { get: jest.fn(), set: jest.fn(), remove: jest.fn(), clear: jest.fn(), }, }; } function mountWith({ props: incomingProps, services: incomingServices, }: { props?: jest.Mocked; services?: jest.Mocked; }) { const props = incomingProps ?? makeDefaultProps(); const services = incomingServices ?? makeDefaultServices(); const wrappingComponent: React.FC<{ children: React.ReactNode; }> = ({ children }) => { return ( {children} ); }; const frame = props.editorFrame as ReturnType; const component = mount(, { wrappingComponent }); return { component, frame, props, services }; } beforeEach(() => { core = coreMock.createStart({ basePath: '/testbasepath' }); defaultSavedObjectId = '1234'; defaultDoc = ({ savedObjectId: defaultSavedObjectId, title: 'An extremely cool default document!', expression: 'definitely a valid expression', state: { query: 'kuery', filters: [{ query: { match_phrase: { src: 'test' } } }], }, references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], } as unknown) as Document; core.uiSettings.get.mockImplementation( jest.fn((type) => { if (type === UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS) { return { from: 'now-7d', to: 'now' }; } else if (type === UI_SETTINGS.SEARCH_QUERY_LANGUAGE) { return 'kuery'; } else if (type === 'state:storeInSessionStorage') { return false; } else { return []; } }) ); }); it('renders the editor frame', () => { const { frame } = mountWith({}); expect(frame.mount.mock.calls).toMatchInlineSnapshot(` Array [ Array [
, Object { "dateRange": Object { "fromDate": "2021-01-10T04:00:00.000Z", "toDate": "2021-01-10T08:00:00.000Z", }, "doc": undefined, "filters": Array [], "initialContext": undefined, "onChange": [Function], "onError": [Function], "query": Object { "language": "kuery", "query": "", }, "savedQuery": undefined, "searchSessionId": "sessionId-1", "showNoDataPopover": [Function], }, ], ] `); }); it('clears app filters on load', () => { const { services } = mountWith({}); expect(services.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([]); }); it('passes global filters to frame', async () => { const services = makeDefaultServices(); const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const pinnedFilter = esFilters.buildExistsFilter(pinnedField, indexPattern); services.data.query.filterManager.getFilters = jest.fn().mockImplementation(() => { return []; }); services.data.query.filterManager.getGlobalFilters = jest.fn().mockImplementation(() => { return [pinnedFilter]; }); const { component, frame } = mountWith({ services }); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ dateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' }, query: { query: '', language: 'kuery' }, filters: [pinnedFilter], }) ); expect(services.data.query.filterManager.getFilters).not.toHaveBeenCalled(); }); it('displays errors from the frame in a toast', () => { const { component, frame, services } = mountWith({}); const onError = frame.mount.mock.calls[0][1].onError; onError({ message: 'error' }); component.update(); expect(services.notifications.toasts.addDanger).toHaveBeenCalled(); }); describe('breadcrumbs', () => { const breadcrumbDocSavedObjectId = defaultSavedObjectId; const breadcrumbDoc = ({ savedObjectId: breadcrumbDocSavedObjectId, title: 'Daaaaaaadaumching!', state: { query: 'fake query', filters: [], }, references: [], } as unknown) as Document; it('sets breadcrumbs when the document title changes', async () => { const { component, services } = mountWith({}); expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([ { text: 'Visualize Library', href: '/testbasepath/app/visualize#/', onClick: expect.anything(), }, { text: 'Create' }, ]); services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(breadcrumbDoc); await act(async () => { component.setProps({ initialInput: { savedObjectId: breadcrumbDocSavedObjectId } }); }); expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([ { text: 'Visualize Library', href: '/testbasepath/app/visualize#/', onClick: expect.anything(), }, { text: 'Daaaaaaadaumching!' }, ]); }); it('sets originatingApp breadcrumb when the document title changes', async () => { const props = makeDefaultProps(); const services = makeDefaultServices(); props.incomingState = { originatingApp: 'coolContainer' }; services.getOriginatingAppName = jest.fn(() => 'The Coolest Container Ever Made'); const { component } = mountWith({ props, services }); expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([ { text: 'The Coolest Container Ever Made', onClick: expect.anything() }, { text: 'Visualize Library', href: '/testbasepath/app/visualize#/', onClick: expect.anything(), }, { text: 'Create' }, ]); services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(breadcrumbDoc); await act(async () => { component.setProps({ initialInput: { savedObjectId: breadcrumbDocSavedObjectId } }); }); expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([ { text: 'The Coolest Container Ever Made', onClick: expect.anything() }, { text: 'Visualize Library', href: '/testbasepath/app/visualize#/', onClick: expect.anything(), }, { text: 'Daaaaaaadaumching!' }, ]); }); }); describe('persistence', () => { it('does not load a document if there is no initial input', () => { const { services } = mountWith({}); expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled(); }); it('loads a document and uses query and filters if initial input is provided', async () => { const { component, frame, services } = mountWith({}); services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({ savedObjectId: defaultSavedObjectId, state: { query: 'fake query', filters: [{ query: { match_phrase: { src: 'test' } } }], }, references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], }); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({ savedObjectId: defaultSavedObjectId, }); expect(services.data.indexPatterns.get).toHaveBeenCalledWith('1'); expect(services.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([ { query: { match_phrase: { src: 'test' } } }, ]); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ query: 'fake query', indexPatterns: [{ id: '1' }], }), {} ); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ doc: expect.objectContaining({ savedObjectId: defaultSavedObjectId, state: expect.objectContaining({ query: 'fake query', filters: [{ query: { match_phrase: { src: 'test' } } }], }), }), }) ); }); it('does not load documents on sequential renders unless the id changes', async () => { const { services, component } = mountWith({}); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1); await act(async () => { component.setProps({ initialInput: { savedObjectId: '5678' } }); }); expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(2); }); it('handles document load errors', async () => { const services = makeDefaultServices(); services.attributeService.unwrapAttributes = jest.fn().mockRejectedValue('failed to load'); const { component, props } = mountWith({ services }); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({ savedObjectId: defaultSavedObjectId, }); expect(services.notifications.toasts.addDanger).toHaveBeenCalled(); expect(props.redirectTo).toHaveBeenCalled(); }); it('adds to the recently accessed list on load', async () => { const { component, services } = mountWith({}); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); expect(services.chrome.recentlyAccessed.add).toHaveBeenCalledWith( '/app/lens#/edit/1234', 'An extremely cool default document!', '1234' ); }); describe('save buttons', () => { interface SaveProps { newCopyOnSave: boolean; returnToOrigin?: boolean; newTitle: string; } function getButton(inst: ReactWrapper): TopNavMenuData { return (inst .find('[data-test-subj="lnsApp_topNav"]') .prop('config') as TopNavMenuData[]).find( (button) => button.testId === 'lnsApp_saveButton' )!; } async function testSave(inst: ReactWrapper, saveProps: SaveProps) { await getButton(inst).run(inst.getDOMNode()); inst.update(); const handler = inst.find('SavedObjectSaveModalOrigin').prop('onSave') as ( p: unknown ) => void; handler(saveProps); } async function save({ lastKnownDoc = { references: [], state: { filters: [], }, }, initialSavedObjectId, ...saveProps }: SaveProps & { lastKnownDoc?: object; initialSavedObjectId?: string; }) { const props = { ...makeDefaultProps(), initialInput: initialSavedObjectId ? { savedObjectId: initialSavedObjectId, id: '5678' } : undefined, }; const services = makeDefaultServices(); services.attributeService.wrapAttributes = jest .fn() .mockImplementation(async ({ savedObjectId }) => ({ savedObjectId: savedObjectId || 'aaa', })); services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({ savedObjectId: initialSavedObjectId ?? 'aaa', references: [], state: { query: 'fake query', filters: [], }, } as jest.ResolvedValue); let frame: jest.Mocked = {} as jest.Mocked; let component: ReactWrapper = {} as ReactWrapper; await act(async () => { const { frame: newFrame, component: newComponent } = mountWith({ services, props }); frame = newFrame; component = newComponent; }); if (initialSavedObjectId) { expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1); } else { expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled(); } const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: { savedObjectId: initialSavedObjectId, ...lastKnownDoc } as Document, isSaveable: true, }) ); component.update(); expect(getButton(component).disableButton).toEqual(false); await act(async () => { testSave(component, { ...saveProps }); }); return { props, services, component, frame }; } it('shows a disabled save button when the user does not have permissions', async () => { const services = makeDefaultServices(); services.application = { ...services.application, capabilities: { ...services.application.capabilities, visualize: { save: false, saveQuery: false, show: true }, }, }; const { component, frame } = mountWith({ services }); expect(getButton(component).disableButton).toEqual(true); const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: ({ savedObjectId: 'will save this' } as unknown) as Document, isSaveable: true, }) ); component.update(); expect(getButton(component).disableButton).toEqual(true); }); it('shows a save button that is enabled when the frame has provided its state and does not show save and return or save as', async () => { const { component, frame } = mountWith({}); expect(getButton(component).disableButton).toEqual(true); const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: ({ savedObjectId: 'will save this' } as unknown) as Document, isSaveable: true, }) ); component.update(); expect(getButton(component).disableButton).toEqual(false); await act(async () => { const topNavMenuConfig = component.find(TopNavMenu).prop('config'); expect(topNavMenuConfig).not.toContainEqual( expect.objectContaining(navMenuItems.expectedSaveAndReturnButton) ); expect(topNavMenuConfig).not.toContainEqual( expect.objectContaining(navMenuItems.expectedSaveAsButton) ); expect(topNavMenuConfig).toContainEqual( expect.objectContaining(navMenuItems.expectedSaveButton) ); }); }); it('Shows Save and Return and Save As buttons in create by value mode with originating app', async () => { const props = makeDefaultProps(); const services = makeDefaultServices(); services.dashboardFeatureFlag = { allowByValueEmbeddables: true }; props.incomingState = { originatingApp: 'ultraDashboard', valueInput: { id: 'whatchaGonnaDoWith', attributes: { title: 'whatcha gonna do with all these references? All these references in your value Input', references: [] as SavedObjectReference[], }, } as LensByValueInput, }; const { component } = mountWith({ props, services }); await act(async () => { const topNavMenuConfig = component.find(TopNavMenu).prop('config'); expect(topNavMenuConfig).toContainEqual( expect.objectContaining(navMenuItems.expectedSaveAndReturnButton) ); expect(topNavMenuConfig).toContainEqual( expect.objectContaining(navMenuItems.expectedSaveAsButton) ); expect(topNavMenuConfig).not.toContainEqual( expect.objectContaining(navMenuItems.expectedSaveButton) ); }); }); it('Shows Save and Return and Save As buttons in edit by reference mode', async () => { const props = makeDefaultProps(); props.initialInput = { savedObjectId: defaultSavedObjectId, id: '5678' }; props.incomingState = { originatingApp: 'ultraDashboard', }; const { component } = mountWith({ props }); await act(async () => { const topNavMenuConfig = component.find(TopNavMenu).prop('config'); expect(topNavMenuConfig).toContainEqual( expect.objectContaining(navMenuItems.expectedSaveAndReturnButton) ); expect(topNavMenuConfig).toContainEqual( expect.objectContaining(navMenuItems.expectedSaveAsButton) ); expect(topNavMenuConfig).not.toContainEqual( expect.objectContaining(navMenuItems.expectedSaveButton) ); }); }); it('saves new docs', async () => { const { props, services } = await save({ initialSavedObjectId: undefined, newCopyOnSave: false, newTitle: 'hello there', }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith( expect.objectContaining({ savedObjectId: undefined, title: 'hello there', }), true, undefined ); expect(props.redirectTo).toHaveBeenCalledWith('aaa'); expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith( "Saved 'hello there'" ); }); it('adds to the recently accessed list on save', async () => { const { services } = await save({ initialSavedObjectId: undefined, newCopyOnSave: false, newTitle: 'hello there', }); expect(services.chrome.recentlyAccessed.add).toHaveBeenCalledWith( '/app/lens#/edit/aaa', 'hello there', 'aaa' ); }); it('saves the latest doc as a copy', async () => { const { props, services, component } = await save({ initialSavedObjectId: defaultSavedObjectId, newCopyOnSave: true, newTitle: 'hello there', }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith( expect.objectContaining({ title: 'hello there', }), true, undefined ); expect(props.redirectTo).toHaveBeenCalledWith(defaultSavedObjectId); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledTimes(1); expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith( "Saved 'hello there'" ); }); it('saves existing docs', async () => { const { props, services, component } = await save({ initialSavedObjectId: defaultSavedObjectId, newCopyOnSave: false, newTitle: 'hello there', }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith( expect.objectContaining({ savedObjectId: defaultSavedObjectId, title: 'hello there', }), true, { id: '5678', savedObjectId: defaultSavedObjectId } ); expect(props.redirectTo).not.toHaveBeenCalled(); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1); expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith( "Saved 'hello there'" ); }); it('handles save failure by showing a warning, but still allows another save', async () => { const services = makeDefaultServices(); services.attributeService.wrapAttributes = jest .fn() .mockRejectedValue({ message: 'failed' }); const { component, props, frame } = mountWith({ services }); const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: ({ id: undefined } as unknown) as Document, isSaveable: true, }) ); component.update(); await act(async () => { testSave(component, { newCopyOnSave: false, newTitle: 'hello there' }); }); expect(props.redirectTo).not.toHaveBeenCalled(); expect(getButton(component).disableButton).toEqual(false); }); it('saves new doc and redirects to originating app', async () => { const { props, services } = await save({ initialSavedObjectId: undefined, returnToOrigin: true, newCopyOnSave: false, newTitle: 'hello there', }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith( expect.objectContaining({ savedObjectId: undefined, title: 'hello there', }), true, undefined ); expect(props.redirectToOrigin).toHaveBeenCalledWith({ input: { savedObjectId: 'aaa' }, isCopied: false, }); }); it('saves app filters and does not save pinned filters', async () => { const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const unpinned = esFilters.buildExistsFilter(field, indexPattern); const pinned = esFilters.buildExistsFilter(pinnedField, indexPattern); await act(async () => { FilterManager.setFiltersStore([pinned], esFilters.FilterStateStore.GLOBAL_STATE); }); const { services } = await save({ initialSavedObjectId: defaultSavedObjectId, newCopyOnSave: false, newTitle: 'hello there2', lastKnownDoc: { expression: 'kibana 3', state: { filters: [pinned, unpinned], }, }, }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith( { savedObjectId: defaultSavedObjectId, title: 'hello there2', expression: 'kibana 3', state: { filters: [unpinned], }, }, true, { id: '5678', savedObjectId: defaultSavedObjectId } ); }); it('checks for duplicate title before saving', async () => { const services = makeDefaultServices(); services.attributeService.wrapAttributes = jest .fn() .mockReturnValue(Promise.resolve({ savedObjectId: '123' })); const { component, frame } = mountWith({ services }); const onChange = frame.mount.mock.calls[0][1].onChange; await act(async () => onChange({ filterableIndexPatterns: [], doc: ({ savedObjectId: '123' } as unknown) as Document, isSaveable: true, }) ); component.update(); await act(async () => { component.setProps({ initialInput: { savedObjectId: '123' } }); getButton(component).run(component.getDOMNode()); }); component.update(); const onTitleDuplicate = jest.fn(); await act(async () => { component.find(SavedObjectSaveModal).prop('onSave')({ onTitleDuplicate, isTitleDuplicateConfirmed: false, newCopyOnSave: false, newDescription: '', newTitle: 'test', }); }); expect(checkForDuplicateTitle).toHaveBeenCalledWith( expect.objectContaining({ id: '123' }), false, onTitleDuplicate, expect.anything() ); }); it('does not show the copy button on first save', async () => { const { component, frame } = mountWith({}); const onChange = frame.mount.mock.calls[0][1].onChange; await act(async () => onChange({ filterableIndexPatterns: [], doc: ({} as unknown) as Document, isSaveable: true, }) ); component.update(); await act(async () => getButton(component).run(component.getDOMNode())); component.update(); expect(component.find(SavedObjectSaveModal).prop('showCopyOnSave')).toEqual(false); }); }); }); describe('download button', () => { function getButton(inst: ReactWrapper): TopNavMenuData { return (inst .find('[data-test-subj="lnsApp_topNav"]') .prop('config') as TopNavMenuData[]).find( (button) => button.testId === 'lnsApp_downloadCSVButton' )!; } it('should be disabled when no data is available', async () => { const { component, frame } = mountWith({}); const onChange = frame.mount.mock.calls[0][1].onChange; await act(async () => onChange({ filterableIndexPatterns: [], doc: ({} as unknown) as Document, isSaveable: true, }) ); component.update(); expect(getButton(component).disableButton).toEqual(true); }); it('should disable download when not saveable', async () => { const { component, frame } = mountWith({}); const onChange = frame.mount.mock.calls[0][1].onChange; await act(async () => onChange({ filterableIndexPatterns: [], doc: ({} as unknown) as Document, isSaveable: false, activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, }) ); component.update(); expect(getButton(component).disableButton).toEqual(true); }); it('should still be enabled even if the user is missing save permissions', async () => { const services = makeDefaultServices(); services.application = { ...services.application, capabilities: { ...services.application.capabilities, visualize: { save: false, saveQuery: false, show: true }, }, }; const { component, frame } = mountWith({ services }); const onChange = frame.mount.mock.calls[0][1].onChange; await act(async () => onChange({ filterableIndexPatterns: [], doc: ({} as unknown) as Document, isSaveable: true, activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, }) ); component.update(); expect(getButton(component).disableButton).toEqual(false); }); }); describe('query bar state management', () => { it('uses the default time and query language settings', () => { const { frame } = mountWith({}); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ query: { query: '', language: 'kuery' }, dateRangeFrom: 'now-7d', dateRangeTo: 'now', }), {} ); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ dateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' }, query: { query: '', language: 'kuery' }, }) ); }); it('updates the index patterns when the editor frame is changed', async () => { const { component, frame } = mountWith({}); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ indexPatterns: [], }), {} ); const onChange = frame.mount.mock.calls[0][1].onChange; await act(async () => { onChange({ filterableIndexPatterns: ['1'], doc: ({ id: undefined } as unknown) as Document, isSaveable: true, }); }); component.update(); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ indexPatterns: [{ id: '1' }], }), {} ); // Do it again to verify that the dirty checking is done right await act(async () => { onChange({ filterableIndexPatterns: ['2'], doc: ({ id: undefined } as unknown) as Document, isSaveable: true, }); }); component.update(); expect(TopNavMenu).toHaveBeenLastCalledWith( expect.objectContaining({ indexPatterns: [{ id: '2' }], }), {} ); }); it('updates the editor frame when the user changes query or time in the search bar', () => { const { component, frame, services } = mountWith({}); (services.data.query.timefilter.timefilter.calculateBounds as jest.Mock).mockReturnValue({ min: moment('2021-01-09T04:00:00.000Z'), max: moment('2021-01-09T08:00:00.000Z'), }); act(() => component.find(TopNavMenu).prop('onQuerySubmit')!({ dateRange: { from: 'now-14d', to: 'now-7d' }, query: { query: 'new', language: 'lucene' }, }) ); component.update(); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ query: { query: 'new', language: 'lucene' }, dateRangeFrom: 'now-14d', dateRangeTo: 'now-7d', }), {} ); expect(services.data.query.timefilter.timefilter.setTime).toHaveBeenCalledWith({ from: 'now-14d', to: 'now-7d', }); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ dateRange: { fromDate: '2021-01-09T04:00:00.000Z', toDate: '2021-01-09T08:00:00.000Z' }, query: { query: 'new', language: 'lucene' }, }) ); }); it('updates the filters when the user changes them', () => { const { component, frame, services } = mountWith({}); const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; act(() => services.data.query.filterManager.setFilters([ esFilters.buildExistsFilter(field, indexPattern), ]) ); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ filters: [esFilters.buildExistsFilter(field, indexPattern)], }) ); }); it('updates the searchSessionId when the user changes query or time in the search bar', () => { const { component, frame, services } = mountWith({}); act(() => component.find(TopNavMenu).prop('onQuerySubmit')!({ dateRange: { from: 'now-14d', to: 'now-7d' }, query: { query: '', language: 'lucene' }, }) ); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ searchSessionId: `sessionId-1`, }) ); // trigger again, this time changing just the query act(() => component.find(TopNavMenu).prop('onQuerySubmit')!({ dateRange: { from: 'now-14d', to: 'now-7d' }, query: { query: 'new', language: 'lucene' }, }) ); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ searchSessionId: `sessionId-2`, }) ); const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; act(() => services.data.query.filterManager.setFilters([ esFilters.buildExistsFilter(field, indexPattern), ]) ); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ searchSessionId: `sessionId-3`, }) ); }); }); describe('saved query handling', () => { it('does not allow saving when the user is missing the saveQuery permission', () => { const services = makeDefaultServices(); services.application = { ...services.application, capabilities: { ...services.application.capabilities, visualize: { save: false, saveQuery: false, show: true }, }, }; mountWith({ services }); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ showSaveQuery: false }), {} ); }); it('persists the saved query ID when the query is saved', () => { const { component } = mountWith({}); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ showSaveQuery: true, savedQuery: undefined, onSaved: expect.any(Function), onSavedQueryUpdated: expect.any(Function), onClearSavedQuery: expect.any(Function), }), {} ); act(() => { component.find(TopNavMenu).prop('onSaved')!({ id: '1', attributes: { title: '', description: '', query: { query: '', language: 'lucene' }, }, }); }); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ savedQuery: { id: '1', attributes: { title: '', description: '', query: { query: '', language: 'lucene' }, }, }, }), {} ); }); it('changes the saved query ID when the query is updated', () => { const { component } = mountWith({}); act(() => { component.find(TopNavMenu).prop('onSaved')!({ id: '1', attributes: { title: '', description: '', query: { query: '', language: 'lucene' }, }, }); }); act(() => { component.find(TopNavMenu).prop('onSavedQueryUpdated')!({ id: '2', attributes: { title: 'new title', description: '', query: { query: '', language: 'lucene' }, }, }); }); expect(TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ savedQuery: { id: '2', attributes: { title: 'new title', description: '', query: { query: '', language: 'lucene' }, }, }, }), {} ); }); it('clears all existing unpinned filters when the active saved query is cleared', () => { const { component, frame, services } = mountWith({}); act(() => component.find(TopNavMenu).prop('onQuerySubmit')!({ dateRange: { from: 'now-14d', to: 'now-7d' }, query: { query: 'new', language: 'lucene' }, }) ); const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const unpinned = esFilters.buildExistsFilter(field, indexPattern); const pinned = esFilters.buildExistsFilter(pinnedField, indexPattern); FilterManager.setFiltersStore([pinned], esFilters.FilterStateStore.GLOBAL_STATE); act(() => services.data.query.filterManager.setFilters([pinned, unpinned])); component.update(); act(() => component.find(TopNavMenu).prop('onClearSavedQuery')!()); component.update(); expect(frame.mount).toHaveBeenLastCalledWith( expect.any(Element), expect.objectContaining({ filters: [pinned], }) ); }); }); describe('search session id management', () => { it('updates the searchSessionId when the query is updated', () => { const { component, frame } = mountWith({}); act(() => { component.find(TopNavMenu).prop('onSaved')!({ id: '1', attributes: { title: '', description: '', query: { query: '', language: 'lucene' }, }, }); }); act(() => { component.find(TopNavMenu).prop('onSavedQueryUpdated')!({ id: '2', attributes: { title: 'new title', description: '', query: { query: '', language: 'lucene' }, }, }); }); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ searchSessionId: `sessionId-2`, }) ); }); it('updates the searchSessionId when the active saved query is cleared', () => { const { component, frame, services } = mountWith({}); act(() => component.find(TopNavMenu).prop('onQuerySubmit')!({ dateRange: { from: 'now-14d', to: 'now-7d' }, query: { query: 'new', language: 'lucene' }, }) ); const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const unpinned = esFilters.buildExistsFilter(field, indexPattern); const pinned = esFilters.buildExistsFilter(pinnedField, indexPattern); FilterManager.setFiltersStore([pinned], esFilters.FilterStateStore.GLOBAL_STATE); act(() => services.data.query.filterManager.setFilters([pinned, unpinned])); component.update(); act(() => component.find(TopNavMenu).prop('onClearSavedQuery')!()); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ searchSessionId: `sessionId-2`, }) ); }); const mockUpdate = { filterableIndexPatterns: [], doc: { title: '', description: '', visualizationType: '', state: { datasourceStates: {}, visualization: {}, filters: [], query: { query: '', language: 'lucene' }, }, references: [], }, isSaveable: true, activeData: undefined, }; it('does not update the searchSessionId when the state changes', () => { const { component, frame } = mountWith({}); act(() => { (component.find(NativeRenderer).prop('nativeProps') as EditorFrameProps).onChange( mockUpdate ); }); component.update(); expect(frame.mount).not.toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ searchSessionId: `sessionId-2`, }) ); }); it('does update the searchSessionId when the state changes and too much time passed', () => { const { component, frame, services } = mountWith({}); // time range is 100,000ms ago to 30,000ms ago (that's a lag of 30 percent) (services.data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 30000)); (services.data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({ from: 'now-2m', to: 'now', }); (services.data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({ min: moment(Date.now() - 100000), max: moment(Date.now() - 30000), }); act(() => { (component.find(NativeRenderer).prop('nativeProps') as EditorFrameProps).onChange( mockUpdate ); }); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ searchSessionId: `sessionId-2`, }) ); }); it('does not update the searchSessionId when the state changes and too little time has passed', () => { const { component, frame, services } = mountWith({}); // time range is 100,000ms ago to 300ms ago (that's a lag of .3 percent, not enough to trigger a session update) (services.data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 300)); (services.data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({ from: 'now-2m', to: 'now', }); (services.data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({ min: moment(Date.now() - 100000), max: moment(Date.now() - 300), }); act(() => { (component.find(NativeRenderer).prop('nativeProps') as EditorFrameProps).onChange( mockUpdate ); }); component.update(); expect(frame.mount).not.toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ searchSessionId: `sessionId-2`, }) ); }); }); describe('showing a confirm message when leaving', () => { let defaultLeave: jest.Mock; let confirmLeave: jest.Mock; beforeEach(() => { defaultLeave = jest.fn(); confirmLeave = jest.fn(); }); it('should not show a confirm message if there is no expression to save', () => { const { props } = mountWith({}); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); expect(defaultLeave).toHaveBeenCalled(); expect(confirmLeave).not.toHaveBeenCalled(); }); it('does not confirm if the user is missing save permissions', () => { const services = makeDefaultServices(); services.application = { ...services.application, capabilities: { ...services.application.capabilities, visualize: { save: false, saveQuery: false, show: true }, }, }; const { component, frame, props } = mountWith({ services }); const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: ({ savedObjectId: undefined, references: [], } as unknown) as Document, isSaveable: true, }) ); component.update(); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); expect(defaultLeave).toHaveBeenCalled(); expect(confirmLeave).not.toHaveBeenCalled(); }); it('should confirm when leaving with an unsaved doc', () => { const { component, frame, props } = mountWith({}); const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: ({ savedObjectId: undefined, state: {} } as unknown) as Document, isSaveable: true, }) ); component.update(); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); expect(confirmLeave).toHaveBeenCalled(); expect(defaultLeave).not.toHaveBeenCalled(); }); it('should confirm when leaving with unsaved changes to an existing doc', async () => { const { component, frame, props } = mountWith({}); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: ({ savedObjectId: defaultSavedObjectId, references: [], } as unknown) as Document, isSaveable: true, }) ); component.update(); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); expect(confirmLeave).toHaveBeenCalled(); expect(defaultLeave).not.toHaveBeenCalled(); }); it('should not confirm when changes are saved', async () => { const { component, frame, props } = mountWith({}); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: defaultDoc, isSaveable: true, }) ); component.update(); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); expect(defaultLeave).toHaveBeenCalled(); expect(confirmLeave).not.toHaveBeenCalled(); }); it('should confirm when the latest doc is invalid', async () => { const { component, frame, props } = mountWith({}); await act(async () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); const onChange = frame.mount.mock.calls[0][1].onChange; act(() => onChange({ filterableIndexPatterns: [], doc: ({ savedObjectId: defaultSavedObjectId, references: [] } as unknown) as Document, isSaveable: true, }) ); component.update(); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); expect(confirmLeave).toHaveBeenCalled(); expect(defaultLeave).not.toHaveBeenCalled(); }); }); });