/*
* 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((el, props) => {}),
unmount: jest.fn(() => {}),
};
}
function createMockSearchService() {
let sessionIdCounter = 1;
return {
session: {
start: jest.fn(() => `sessionId-${sessionIdCounter++}`),
clear: jest.fn(),
},
};
}
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$: () => ({
subscribe: ({ next }: { next: () => void }) => {
return next;
},
}),
};
}
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();
});
});
});