[Enterprise Search] Move http out of React Context and to Kea Logic; prefer directly mounting (#78167)

* Remove HttpProvider in favor of mounting HttpLogic directly w/ props

- removes need for initializeHttp call
- ensures http value is loaded into HttpLogic as soon as possible / should never load in as null, reducing # of rerenders/checks

see: https://kea.js.org/docs/guide/advanced#mounting-and-unmounting

* Update simplest components using http for sendTelemetry

* Update simplest tests for components using HttpLogic + default Kea mocks

- Kea mock import should now contain mock default values which can be overridden

* Update moderately complex tests using HttpLogic

send_telemetry:
- refactor to use shallow (w/ useEffect mocked) vs mount
- check mockHttpValues directly

engine_table:
- refactor to use mount w/ an I18nProvider rather than mountWithContext helper (which we'll likely need to overhaul in the future)
- assert mockHttpValues directly

* Update EngineOverview to HttpLogic + refactors

EngineOverview:
- Change use of FormattedMessage to i18n.translate (simpler, no provider required)

Tests:
- Create mock values/actions for FlashMessages, since EngineOverview calls it
- Create combined mockAllValues obj for easier overriding
- Create setMockValues helper for easier test overriding (credit to @scottybollinger for the idea!)
- Update engine_overview tests to setMockValues instead of passing context to mountWithAsyncContext
- Fix mountWithAsyncContext to accept an undefined obj

* Remove http from KibanaContext

- it should now only live in HttpLogic 🔥

* Remove FlashMessagesProvider in favor of mounting logic directly w/ props

- send history as prop
- refactor out now-unnecessary listenToHistory (we can just do it directly in afterMount without worrying about duplicate react rerenders)
- add mount helper

Tests:
- refactor history.listen mock to mockHistory (so that set_message_helpers can use it as well)
- use mountFlashMessagesLogic + create an even shorter mount() helper (credit to @JasonStoltz for the idea!)
- refactor out DEFAULT_VALUES since we're not really using it anywhere else in the file, and it's not super applicable to this store
- update history listener tests to account for logic occurring immediately on mount
This commit is contained in:
Constance 2020-09-22 12:33:37 -07:00 committed by GitHub
parent 426df45c6f
commit 42026cbbf5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 269 additions and 333 deletions

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const mockFlashMessagesValues = {
messages: [],
queuedMessages: [],
};
export const mockFlashMessagesActions = {
setFlashMessages: jest.fn(),
clearFlashMessages: jest.fn(),
setQueuedMessages: jest.fn(),
clearQueuedMessages: jest.fn(),
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { httpServiceMock } from 'src/core/public/mocks';
export const mockHttpValues = {
http: httpServiceMock.createSetupContract(),
errorConnecting: false,
readOnlyMode: false,
};

View file

@ -7,6 +7,10 @@
export { mockHistory, mockLocation } from './react_router_history.mock';
export { mockKibanaContext } from './kibana_context.mock';
export { mockLicenseContext } from './license_context.mock';
export { mockHttpValues } from './http_logic.mock';
export { mockFlashMessagesValues, mockFlashMessagesActions } from './flash_messages_logic.mock';
export { mockAllValues, mockAllActions, setMockValues } from './kea.mock';
export {
mountWithContext,
mountWithKibanaContext,

View file

@ -4,21 +4,46 @@
* you may not use this file except in compliance with the Elastic License.
*/
/**
* NOTE: These variable names MUST start with 'mock*' in order for
* Jest to accept its use within a jest.mock()
*/
import { mockHttpValues } from './http_logic.mock';
import { mockFlashMessagesValues, mockFlashMessagesActions } from './flash_messages_logic.mock';
export const mockAllValues = {
...mockHttpValues,
...mockFlashMessagesValues,
};
export const mockAllActions = {
...mockFlashMessagesActions,
};
/**
* Import this file directly to mock useValues with a set of default values for all shared logic files.
* Example usage:
*
* import '../../../__mocks__/kea'; // Must come before kea's import, adjust relative path as needed
*/
jest.mock('kea', () => ({
...(jest.requireActual('kea') as object),
useValues: jest.fn(() => ({})),
useActions: jest.fn(() => ({})),
useValues: jest.fn(() => ({ ...mockAllValues })),
useActions: jest.fn(() => ({ ...mockAllActions })),
}));
/**
* Call this function to override a specific set of Kea values while retaining all other defaults
* Example usage within a component test:
*
* import '../../../__mocks__/kea'; // Must come before kea's import, adjust relative path as needed
*
* import { useActions, useValues } from 'kea';
* import '../../../__mocks__/kea';
* import { setMockValues } from ''../../../__mocks__';
*
* it('some test', () => {
* (useValues as jest.Mock).mockImplementationOnce(() => ({ someValue: 'hello' }));
* (useActions as jest.Mock).mockImplementationOnce(() => ({ someAction: () => 'world' }));
* setMockValues({ someValue: 'hello' });
* });
*/
import { useValues } from 'kea';
export const setMockValues = (values: object) => {
(useValues as jest.Mock).mockImplementation(() => ({ ...mockAllValues, ...values }));
};

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { httpServiceMock } from 'src/core/public/mocks';
import { ExternalUrl } from '../shared/enterprise_search_url';
/**
@ -12,7 +11,6 @@ import { ExternalUrl } from '../shared/enterprise_search_url';
* @see enterprise_search/public/index.tsx for the KibanaContext definition/import
*/
export const mockKibanaContext = {
http: httpServiceMock.createSetupContract(),
navigateToUrl: jest.fn(),
setBreadcrumbs: jest.fn(),
setDocTitle: jest.fn(),

View file

@ -67,7 +67,7 @@ export const mountWithKibanaContext = (children: React.ReactNode, context?: obje
*/
export const mountWithAsyncContext = async (
children: React.ReactNode,
context: object
context?: object
): Promise<ReactWrapper> => {
let wrapper: ReactWrapper | undefined;

View file

@ -14,6 +14,7 @@ export const mockHistory = {
location: {
pathname: '/current-path',
},
listen: jest.fn(() => jest.fn()),
};
export const mockLocation = {
key: 'someKey',

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import '../../../../__mocks__/kea.mock';
import '../../../../__mocks__/shallow_usecontext.mock';
import React from 'react';

View file

@ -5,10 +5,12 @@
*/
import React, { useContext } from 'react';
import { useValues } from 'kea';
import { EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { sendTelemetry } from '../../../../shared/telemetry';
import { HttpLogic } from '../../../../shared/http';
import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome';
import { KibanaContext, IKibanaContext } from '../../../../index';
import { CREATE_ENGINES_PATH } from '../../../routes';
@ -18,9 +20,9 @@ import { EngineOverviewHeader } from './header';
import './empty_state.scss';
export const EmptyState: React.FC = () => {
const { http } = useValues(HttpLogic);
const {
externalUrl: { getAppSearchUrl },
http,
} = useContext(KibanaContext) as IKibanaContext;
const buttonProps = {

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import '../../../../__mocks__/kea.mock';
import '../../../../__mocks__/shallow_usecontext.mock';
import React from 'react';

View file

@ -5,6 +5,7 @@
*/
import React, { useContext } from 'react';
import { useValues } from 'kea';
import {
EuiPageHeader,
EuiPageHeaderSection,
@ -16,12 +17,13 @@ import {
import { FormattedMessage } from '@kbn/i18n/react';
import { sendTelemetry } from '../../../../shared/telemetry';
import { HttpLogic } from '../../../../shared/http';
import { KibanaContext, IKibanaContext } from '../../../../index';
export const EngineOverviewHeader: React.FC = () => {
const { http } = useValues(HttpLogic);
const {
externalUrl: { getAppSearchUrl },
http,
} = useContext(KibanaContext) as IKibanaContext;
const buttonProps = {

View file

@ -4,13 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import '../../../__mocks__/kea.mock';
import '../../../__mocks__/react_router_history.mock';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { shallow, ReactWrapper } from 'enzyme';
import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__';
import { mountWithAsyncContext, mockHttpValues, setMockValues } from '../../../__mocks__';
import { LoadingState, EmptyState } from './components';
import { EngineTable } from './engine_table';
@ -18,8 +19,6 @@ import { EngineTable } from './engine_table';
import { EngineOverview } from './';
describe('EngineOverview', () => {
const mockHttp = mockKibanaContext.http;
describe('non-happy-path states', () => {
it('isLoading', () => {
const wrapper = shallow(<EngineOverview />);
@ -28,15 +27,16 @@ describe('EngineOverview', () => {
});
it('isEmpty', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
setMockValues({
http: {
...mockHttp,
...mockHttpValues.http,
get: () => ({
results: [],
meta: { page: { total_results: 0 } },
}),
},
});
const wrapper = await mountWithAsyncContext(<EngineOverview />);
expect(wrapper.find(EmptyState)).toHaveLength(1);
});
@ -65,12 +65,11 @@ describe('EngineOverview', () => {
beforeEach(() => {
jest.clearAllMocks();
setMockValues({ http: { ...mockHttpValues.http, get: mockApi } });
});
it('renders and calls the engines API', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: { ...mockHttp, get: mockApi },
});
const wrapper = await mountWithAsyncContext(<EngineOverview />);
expect(wrapper.find(EngineTable)).toHaveLength(1);
expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', {
@ -84,7 +83,6 @@ describe('EngineOverview', () => {
describe('when on a platinum license', () => {
it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: { ...mockHttp, get: mockApi },
license: { type: 'platinum', isActive: true },
});
@ -103,9 +101,7 @@ describe('EngineOverview', () => {
wrapper.find(EngineTable).prop('pagination');
it('passes down page data from the API', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: { ...mockHttp, get: mockApi },
});
const wrapper = await mountWithAsyncContext(<EngineOverview />);
const pagination = getTablePagination(wrapper);
expect(pagination.totalEngines).toEqual(100);
@ -113,9 +109,7 @@ describe('EngineOverview', () => {
});
it('re-polls the API on page change', async () => {
const wrapper = await mountWithAsyncContext(<EngineOverview />, {
http: { ...mockHttp, get: mockApi },
});
const wrapper = await mountWithAsyncContext(<EngineOverview />);
await act(async () => getTablePagination(wrapper).onPaginate(5));
wrapper.update();

View file

@ -5,6 +5,7 @@
*/
import React, { useContext, useEffect, useState } from 'react';
import { useValues } from 'kea';
import {
EuiPageContent,
EuiPageContentHeader,
@ -12,13 +13,13 @@ import {
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import { FlashMessages } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing';
import { KibanaContext, IKibanaContext } from '../../../index';
import { EngineIcon } from './assets/engine_icon';
import { MetaEngineIcon } from './assets/meta_engine_icon';
@ -38,7 +39,7 @@ interface ISetEnginesCallbacks {
}
export const EngineOverview: React.FC = () => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const { http } = useValues(HttpLogic);
const { license } = useContext(LicenseContext) as ILicenseContext;
const [isLoading, setIsLoading] = useState(true);
@ -94,10 +95,9 @@ export const EngineOverview: React.FC = () => {
<EuiTitle size="s">
<h2>
<EngineIcon />
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.enginesOverview.engines"
defaultMessage="Engines"
/>
{i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.engines', {
defaultMessage: 'Engines',
})}
</h2>
</EuiTitle>
</EuiPageContentHeader>
@ -119,10 +119,9 @@ export const EngineOverview: React.FC = () => {
<EuiTitle size="s">
<h2>
<MetaEngineIcon />
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.enginesOverview.metaEngines"
defaultMessage="Meta Engines"
/>
{i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.metaEngines', {
defaultMessage: 'Meta Engines',
})}
</h2>
</EuiTitle>
</EuiPageContentHeader>

View file

@ -4,10 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import '../../../__mocks__/kea.mock';
import '../../../__mocks__/shallow_usecontext.mock';
import { mockHttpValues } from '../../../__mocks__/';
import React from 'react';
import { mount } from 'enzyme';
import { I18nProvider } from '@kbn/i18n/react';
import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui';
import { mountWithContext } from '../../../__mocks__';
jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
import { sendTelemetry } from '../../../shared/telemetry';
@ -16,22 +21,24 @@ import { EngineTable } from './engine_table';
describe('EngineTable', () => {
const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream
const wrapper = mountWithContext(
<EngineTable
data={[
{
name: 'test-engine',
created_at: 'Fri, 1 Jan 1970 12:00:00 +0000',
document_count: 99999,
field_count: 10,
},
]}
pagination={{
totalEngines: 50,
pageIndex: 0,
onPaginate,
}}
/>
const wrapper = mount(
<I18nProvider>
<EngineTable
data={[
{
name: 'test-engine',
created_at: 'Fri, 1 Jan 1970 12:00:00 +0000',
document_count: 99999,
field_count: 10,
},
]}
pagination={{
totalEngines: 50,
pageIndex: 0,
onPaginate,
}}
/>
</I18nProvider>
);
const table = wrapper.find(EuiBasicTable);
@ -56,7 +63,7 @@ describe('EngineTable', () => {
link.simulate('click');
expect(sendTelemetry).toHaveBeenCalledWith({
http: expect.any(Object),
http: mockHttpValues.http,
product: 'app_search',
action: 'clicked',
metric: 'engine_table_link',
@ -71,10 +78,16 @@ describe('EngineTable', () => {
});
it('handles empty data', () => {
const emptyWrapper = mountWithContext(
<EngineTable data={[]} pagination={{ totalEngines: 0, pageIndex: 0, onPaginate: () => {} }} />
const emptyWrapper = mount(
<I18nProvider>
<EngineTable
data={[]}
pagination={{ totalEngines: 0, pageIndex: 0, onPaginate: () => {} }}
/>
</I18nProvider>
);
const emptyTable = emptyWrapper.find(EuiBasicTable);
expect(emptyTable.prop('pagination').pageIndex).toEqual(0);
});
});

View file

@ -5,11 +5,13 @@
*/
import React, { useContext } from 'react';
import { useValues } from 'kea';
import { EuiBasicTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui';
import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { sendTelemetry } from '../../../shared/telemetry';
import { HttpLogic } from '../../../shared/http';
import { KibanaContext, IKibanaContext } from '../../../index';
import { getEngineRoute } from '../../routes';
@ -40,9 +42,9 @@ export const EngineTable: React.FC<IEngineTableProps> = ({
data,
pagination: { totalEngines, pageIndex, onPaginate },
}) => {
const { http } = useValues(HttpLogic);
const {
externalUrl: { getAppSearchUrl },
http,
} = useContext(KibanaContext) as IKibanaContext;
const engineLinkProps = (name: string) => ({

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import '../../../__mocks__/kea.mock';
import React from 'react';
import { shallow } from 'enzyme';

View file

@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext } from 'react';
import React from 'react';
import { useValues } from 'kea';
import upperFirst from 'lodash/upperFirst';
import snakeCase from 'lodash/snakeCase';
import { i18n } from '@kbn/i18n';
@ -12,7 +13,7 @@ import { EuiCard, EuiTextColor } from '@elastic/eui';
import { EuiButton } from '../../../shared/react_router_helpers';
import { sendTelemetry } from '../../../shared/telemetry';
import { KibanaContext, IKibanaContext } from '../../../index';
import { HttpLogic } from '../../../shared/http';
import './product_card.scss';
@ -28,7 +29,7 @@ interface IProductCard {
}
export const ProductCard: React.FC<IProductCard> = ({ product, image }) => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const { http } = useValues(HttpLogic);
return (
<EuiCard

View file

@ -13,24 +13,17 @@ import { Store } from 'redux';
import { getContext, resetContext } from 'kea';
import { I18nProvider } from '@kbn/i18n/react';
import {
AppMountParameters,
CoreStart,
ApplicationStart,
HttpSetup,
ChromeBreadcrumb,
} from 'src/core/public';
import { AppMountParameters, CoreStart, ApplicationStart, ChromeBreadcrumb } from 'src/core/public';
import { ClientConfigType, ClientData, PluginsSetup } from '../plugin';
import { LicenseProvider } from './shared/licensing';
import { FlashMessagesProvider } from './shared/flash_messages';
import { HttpProvider } from './shared/http';
import { mountHttpLogic } from './shared/http';
import { mountFlashMessagesLogic } from './shared/flash_messages';
import { IExternalUrl } from './shared/enterprise_search_url';
import { IInitialAppData } from '../../common/types';
export interface IKibanaContext {
config: { host?: string };
externalUrl: IExternalUrl;
http: HttpSetup;
navigateToUrl: ApplicationStart['navigateToUrl'];
setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void;
setDocTitle(title: string): void;
@ -55,13 +48,20 @@ export const renderApp = (
resetContext({ createStore: true });
const store = getContext().store as Store;
const unmountHttpLogic = mountHttpLogic({
http: core.http,
errorConnecting,
readOnlyMode: initialData.readOnlyMode,
});
const unmountFlashMessagesLogic = mountFlashMessagesLogic({ history: params.history });
ReactDOM.render(
<I18nProvider>
<KibanaContext.Provider
value={{
config,
externalUrl,
http: core.http,
navigateToUrl: core.application.navigateToUrl,
setBreadcrumbs: core.chrome.setBreadcrumbs,
setDocTitle: core.chrome.docTitle.change,
@ -69,12 +69,6 @@ export const renderApp = (
>
<LicenseProvider license$={plugins.licensing.license$}>
<Provider store={store}>
<HttpProvider
http={core.http}
errorConnecting={errorConnecting}
readOnlyMode={initialData.readOnlyMode}
/>
<FlashMessagesProvider history={params.history} />
<Router history={params.history}>
<App {...initialData} />
</Router>
@ -86,6 +80,8 @@ export const renderApp = (
);
return () => {
ReactDOM.unmountComponentAtNode(params.element);
unmountHttpLogic();
unmountFlashMessagesLogic();
};
};

View file

@ -6,23 +6,25 @@
import { resetContext } from 'kea';
import { FlashMessagesLogic, IFlashMessage } from './flash_messages_logic';
import { mockHistory } from '../../__mocks__';
import { FlashMessagesLogic, mountFlashMessagesLogic, IFlashMessage } from './';
describe('FlashMessagesLogic', () => {
const DEFAULT_VALUES = {
messages: [],
queuedMessages: [],
historyListener: null,
};
const mount = () => mountFlashMessagesLogic({ history: mockHistory as any });
beforeEach(() => {
jest.clearAllMocks();
resetContext({});
});
it('has expected default values', () => {
FlashMessagesLogic.mount();
expect(FlashMessagesLogic.values).toEqual(DEFAULT_VALUES);
it('has default values', () => {
mount();
expect(FlashMessagesLogic.values).toEqual({
messages: [],
queuedMessages: [],
historyListener: expect.any(Function),
});
});
describe('setFlashMessages()', () => {
@ -33,7 +35,7 @@ describe('FlashMessagesLogic', () => {
{ type: 'info', message: 'Everything is fine, nothing is ruined' },
];
FlashMessagesLogic.mount();
mount();
FlashMessagesLogic.actions.setFlashMessages(messages);
expect(FlashMessagesLogic.values.messages).toEqual(messages);
@ -42,7 +44,7 @@ describe('FlashMessagesLogic', () => {
it('automatically converts to an array if a single message obj is passed in', () => {
const message = { type: 'success', message: 'I turn into an array!' } as IFlashMessage;
FlashMessagesLogic.mount();
mount();
FlashMessagesLogic.actions.setFlashMessages(message);
expect(FlashMessagesLogic.values.messages).toEqual([message]);
@ -51,7 +53,7 @@ describe('FlashMessagesLogic', () => {
describe('clearFlashMessages()', () => {
it('sets messages back to an empty array', () => {
FlashMessagesLogic.mount();
mount();
FlashMessagesLogic.actions.setFlashMessages('test' as any);
FlashMessagesLogic.actions.clearFlashMessages();
@ -63,7 +65,7 @@ describe('FlashMessagesLogic', () => {
it('sets an array of messages', () => {
const queuedMessage: IFlashMessage = { type: 'error', message: 'You deleted a thing' };
FlashMessagesLogic.mount();
mount();
FlashMessagesLogic.actions.setQueuedMessages(queuedMessage);
expect(FlashMessagesLogic.values.queuedMessages).toEqual([queuedMessage]);
@ -72,7 +74,7 @@ describe('FlashMessagesLogic', () => {
describe('clearQueuedMessages()', () => {
it('sets queued messages back to an empty array', () => {
FlashMessagesLogic.mount();
mount();
FlashMessagesLogic.actions.setQueuedMessages('test' as any);
FlashMessagesLogic.actions.clearQueuedMessages();
@ -83,30 +85,25 @@ describe('FlashMessagesLogic', () => {
describe('history listener logic', () => {
describe('setHistoryListener()', () => {
it('sets the historyListener value', () => {
FlashMessagesLogic.mount();
mount();
FlashMessagesLogic.actions.setHistoryListener('test' as any);
expect(FlashMessagesLogic.values.historyListener).toEqual('test');
});
});
describe('listenToHistory()', () => {
describe('on mount', () => {
it('listens for history changes and clears messages on change', () => {
FlashMessagesLogic.mount();
mount();
expect(mockHistory.listen).toHaveBeenCalled();
FlashMessagesLogic.actions.setQueuedMessages(['queuedMessages'] as any);
jest.spyOn(FlashMessagesLogic.actions, 'clearFlashMessages');
jest.spyOn(FlashMessagesLogic.actions, 'setFlashMessages');
jest.spyOn(FlashMessagesLogic.actions, 'clearQueuedMessages');
jest.spyOn(FlashMessagesLogic.actions, 'setHistoryListener');
const mockListener = jest.fn(() => jest.fn());
const history = { listen: mockListener } as any;
FlashMessagesLogic.actions.listenToHistory(history);
expect(mockListener).toHaveBeenCalled();
expect(FlashMessagesLogic.actions.setHistoryListener).toHaveBeenCalled();
const mockHistoryChange = (mockListener.mock.calls[0] as any)[0];
const mockHistoryChange = (mockHistory.listen.mock.calls[0] as any)[0];
mockHistoryChange();
expect(FlashMessagesLogic.actions.clearFlashMessages).toHaveBeenCalled();
expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([
@ -116,19 +113,20 @@ describe('FlashMessagesLogic', () => {
});
});
describe('beforeUnmount', () => {
it('removes history listener on unmount', () => {
describe('on unmount', () => {
it('removes history listener', () => {
const mockUnlistener = jest.fn();
const unmount = FlashMessagesLogic.mount();
mockHistory.listen.mockReturnValueOnce(mockUnlistener);
FlashMessagesLogic.actions.setHistoryListener(mockUnlistener);
const unmount = mount();
unmount();
expect(mockUnlistener).toHaveBeenCalled();
});
it('does not crash if no listener exists', () => {
const unmount = FlashMessagesLogic.mount();
const unmount = mount();
FlashMessagesLogic.actions.setHistoryListener(null as any);
unmount();
});
});

View file

@ -24,7 +24,6 @@ export interface IFlashMessagesActions {
clearFlashMessages(): void;
setQueuedMessages(messages: IFlashMessage | IFlashMessage[]): { messages: IFlashMessage[] };
clearQueuedMessages(): void;
listenToHistory(history: History): History;
setHistoryListener(historyListener: Function): { historyListener: Function };
}
@ -38,7 +37,6 @@ export const FlashMessagesLogic = kea<MakeLogicType<IFlashMessagesValues, IFlash
clearFlashMessages: () => null,
setQueuedMessages: (messages) => ({ messages: convertToArray(messages) }),
clearQueuedMessages: () => null,
listenToHistory: (history) => history,
setHistoryListener: (historyListener) => ({ historyListener }),
},
reducers: {
@ -63,21 +61,31 @@ export const FlashMessagesLogic = kea<MakeLogicType<IFlashMessagesValues, IFlash
},
],
},
listeners: ({ values, actions }) => ({
listenToHistory: (history) => {
events: ({ props, values, actions }) => ({
afterMount: () => {
// On React Router navigation, clear previous flash messages and load any queued messages
const unlisten = history.listen(() => {
const unlisten = props.history.listen(() => {
actions.clearFlashMessages();
actions.setFlashMessages(values.queuedMessages);
actions.clearQueuedMessages();
});
actions.setHistoryListener(unlisten);
},
}),
events: ({ values }) => ({
beforeUnmount: () => {
const { historyListener: removeHistoryListener } = values;
if (removeHistoryListener) removeHistoryListener();
},
}),
});
/**
* Mount/props helper
*/
interface IFlashMessagesLogicProps {
history: History;
}
export const mountFlashMessagesLogic = (props: IFlashMessagesLogicProps) => {
FlashMessagesLogic(props);
const unmount = FlashMessagesLogic.mount();
return unmount;
};

View file

@ -1,46 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import '../../__mocks__/shallow_usecontext.mock';
import '../../__mocks__/kea.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { useValues, useActions } from 'kea';
import { mockHistory } from '../../__mocks__';
import { FlashMessagesProvider } from './';
describe('FlashMessagesProvider', () => {
const props = { history: mockHistory as any };
const listenToHistory = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useActions as jest.Mock).mockImplementationOnce(() => ({ listenToHistory }));
});
it('does not render', () => {
const wrapper = shallow(<FlashMessagesProvider {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('listens to history on mount', () => {
shallow(<FlashMessagesProvider {...props} />);
expect(listenToHistory).toHaveBeenCalledWith(mockHistory);
});
it('does not add another history listener if one already exists', () => {
(useValues as jest.Mock).mockImplementationOnce(() => ({ historyListener: 'exists' as any }));
shallow(<FlashMessagesProvider {...props} />);
expect(listenToHistory).not.toHaveBeenCalledWith(props);
});
});

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect } from 'react';
import { useValues, useActions } from 'kea';
import { History } from 'history';
import { FlashMessagesLogic } from './flash_messages_logic';
interface IFlashMessagesProviderProps {
history: History;
}
export const FlashMessagesProvider: React.FC<IFlashMessagesProviderProps> = ({ history }) => {
const { historyListener } = useValues(FlashMessagesLogic);
const { listenToHistory } = useActions(FlashMessagesLogic);
useEffect(() => {
if (!historyListener) listenToHistory(history);
}, []);
return null;
};

View file

@ -10,7 +10,7 @@ export {
IFlashMessage,
IFlashMessagesValues,
IFlashMessagesActions,
mountFlashMessagesLogic,
} from './flash_messages_logic';
export { FlashMessagesProvider } from './flash_messages_provider';
export { flashAPIErrors } from './handle_api_errors';
export { setSuccessMessage, setErrorMessage, setQueuedSuccessMessage } from './set_message_helpers';

View file

@ -4,8 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { mockHistory } from '../../__mocks__';
import {
FlashMessagesLogic,
mountFlashMessagesLogic,
setSuccessMessage,
setErrorMessage,
setQueuedSuccessMessage,
@ -15,7 +18,7 @@ describe('Flash Message Helpers', () => {
const message = 'I am a message';
beforeEach(() => {
FlashMessagesLogic.mount();
mountFlashMessagesLogic({ history: mockHistory as any });
});
it('setSuccessMessage()', () => {

View file

@ -8,31 +8,20 @@ import { resetContext } from 'kea';
import { httpServiceMock } from 'src/core/public/mocks';
import { HttpLogic } from './http_logic';
import { HttpLogic, mountHttpLogic } from './http_logic';
describe('HttpLogic', () => {
const mockHttp = httpServiceMock.createSetupContract();
const DEFAULT_VALUES = {
http: null,
httpInterceptors: [],
errorConnecting: false,
readOnlyMode: false,
};
const mount = () => mountHttpLogic({ http: mockHttp });
beforeEach(() => {
jest.clearAllMocks();
resetContext({});
});
it('has expected default values', () => {
HttpLogic.mount();
expect(HttpLogic.values).toEqual(DEFAULT_VALUES);
});
describe('initializeHttp()', () => {
it('sets values based on passed props', () => {
HttpLogic.mount();
HttpLogic.actions.initializeHttp({
describe('mounts', () => {
it('sets values from props', () => {
mountHttpLogic({
http: mockHttp,
errorConnecting: true,
readOnlyMode: true,
@ -40,7 +29,7 @@ describe('HttpLogic', () => {
expect(HttpLogic.values).toEqual({
http: mockHttp,
httpInterceptors: [],
httpInterceptors: expect.any(Array),
errorConnecting: true,
readOnlyMode: true,
});
@ -49,7 +38,9 @@ describe('HttpLogic', () => {
describe('setErrorConnecting()', () => {
it('sets errorConnecting value', () => {
HttpLogic.mount();
mount();
expect(HttpLogic.values.errorConnecting).toEqual(false);
HttpLogic.actions.setErrorConnecting(true);
expect(HttpLogic.values.errorConnecting).toEqual(true);
@ -60,7 +51,9 @@ describe('HttpLogic', () => {
describe('setReadOnlyMode()', () => {
it('sets readOnlyMode value', () => {
HttpLogic.mount();
mount();
expect(HttpLogic.values.readOnlyMode).toEqual(false);
HttpLogic.actions.setReadOnlyMode(true);
expect(HttpLogic.values.readOnlyMode).toEqual(true);
@ -72,10 +65,8 @@ describe('HttpLogic', () => {
describe('http interceptors', () => {
describe('initializeHttpInterceptors()', () => {
beforeEach(() => {
HttpLogic.mount();
mount();
jest.spyOn(HttpLogic.actions, 'setHttpInterceptors');
HttpLogic.actions.initializeHttp({ http: mockHttp });
HttpLogic.actions.initializeHttpInterceptors();
});
it('calls http.intercept and sets an array of interceptors', () => {
@ -165,7 +156,7 @@ describe('HttpLogic', () => {
});
it('sets httpInterceptors and calls all valid remove functions on unmount', () => {
const unmount = HttpLogic.mount();
const unmount = mount();
const httpInterceptors = [jest.fn(), undefined, jest.fn()] as any;
HttpLogic.actions.setHttpInterceptors(httpInterceptors);

View file

@ -7,7 +7,6 @@
import { kea, MakeLogicType } from 'kea';
import { HttpSetup, HttpInterceptorResponseError, HttpResponse } from 'src/core/public';
import { IHttpProviderProps } from './http_provider';
import { READ_ONLY_MODE_HEADER } from '../../../../common/constants';
@ -18,7 +17,6 @@ export interface IHttpValues {
readOnlyMode: boolean;
}
export interface IHttpActions {
initializeHttp({ http, errorConnecting, readOnlyMode }: IHttpProviderProps): IHttpProviderProps;
initializeHttpInterceptors(): void;
setHttpInterceptors(httpInterceptors: Function[]): { httpInterceptors: Function[] };
setErrorConnecting(errorConnecting: boolean): { errorConnecting: boolean };
@ -28,19 +26,13 @@ export interface IHttpActions {
export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
path: ['enterprise_search', 'http_logic'],
actions: {
initializeHttp: (props) => props,
initializeHttpInterceptors: () => null,
setHttpInterceptors: (httpInterceptors) => ({ httpInterceptors }),
setErrorConnecting: (errorConnecting) => ({ errorConnecting }),
setReadOnlyMode: (readOnlyMode) => ({ readOnlyMode }),
},
reducers: {
http: [
(null as unknown) as HttpSetup,
{
initializeHttp: (_, { http }) => http,
},
],
reducers: ({ props }) => ({
http: [props.http, {}],
httpInterceptors: [
[],
{
@ -48,20 +40,18 @@ export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
},
],
errorConnecting: [
false,
props.errorConnecting || false,
{
initializeHttp: (_, { errorConnecting }) => !!errorConnecting,
setErrorConnecting: (_, { errorConnecting }) => errorConnecting,
},
],
readOnlyMode: [
false,
props.readOnlyMode || false,
{
initializeHttp: (_, { readOnlyMode }) => !!readOnlyMode,
setReadOnlyMode: (_, { readOnlyMode }) => readOnlyMode,
},
],
},
}),
listeners: ({ values, actions }) => ({
initializeHttpInterceptors: () => {
const httpInterceptors = [];
@ -103,7 +93,10 @@ export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
actions.setHttpInterceptors(httpInterceptors);
},
}),
events: ({ values }) => ({
events: ({ values, actions }) => ({
afterMount: () => {
actions.initializeHttpInterceptors();
},
beforeUnmount: () => {
values.httpInterceptors.forEach((removeInterceptorFn?: Function) => {
if (removeInterceptorFn) removeInterceptorFn();
@ -112,6 +105,20 @@ export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
}),
});
/**
* Mount/props helper
*/
interface IHttpLogicProps {
http: HttpSetup;
errorConnecting?: boolean;
readOnlyMode?: boolean;
}
export const mountHttpLogic = (props: IHttpLogicProps) => {
HttpLogic(props);
const unmount = HttpLogic.mount();
return unmount;
};
/**
* Small helper that checks whether or not an http call is for an Enterprise Search API
*/

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import '../../__mocks__/shallow_usecontext.mock';
import '../../__mocks__/kea.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { useActions } from 'kea';
import { HttpProvider } from './';
describe('HttpProvider', () => {
const props = {
http: {} as any,
errorConnecting: false,
readOnlyMode: false,
};
const initializeHttp = jest.fn();
const initializeHttpInterceptors = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useActions as jest.Mock).mockImplementationOnce(() => ({
initializeHttp,
initializeHttpInterceptors,
}));
});
it('does not render', () => {
const wrapper = shallow(<HttpProvider {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('calls initialization actions on mount', () => {
shallow(<HttpProvider {...props} />);
expect(initializeHttp).toHaveBeenCalledWith(props);
expect(initializeHttpInterceptors).toHaveBeenCalled();
});
});

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect } from 'react';
import { useActions } from 'kea';
import { HttpSetup } from 'src/core/public';
import { HttpLogic } from './http_logic';
export interface IHttpProviderProps {
http: HttpSetup;
errorConnecting?: boolean;
readOnlyMode?: boolean;
}
export const HttpProvider: React.FC<IHttpProviderProps> = (props) => {
const { initializeHttp, initializeHttpInterceptors } = useActions(HttpLogic);
useEffect(() => {
initializeHttp(props);
initializeHttpInterceptors();
}, []);
return null;
};

View file

@ -4,5 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { HttpLogic, IHttpValues, IHttpActions } from './http_logic';
export { HttpProvider } from './http_provider';
export { HttpLogic, IHttpValues, IHttpActions, mountHttpLogic } from './http_logic';

View file

@ -4,11 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import '../../__mocks__/kea.mock';
import '../../__mocks__/shallow_usecontext.mock';
import { mockHttpValues } from '../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { httpServiceMock } from 'src/core/public/mocks';
import { JSON_HEADER as headers } from '../../../../common/constants';
import { mountWithKibanaContext } from '../../__mocks__';
import {
sendTelemetry,
@ -18,8 +21,6 @@ import {
} from './';
describe('Shared Telemetry Helpers', () => {
const httpMock = httpServiceMock.createSetupContract();
beforeEach(() => {
jest.clearAllMocks();
});
@ -27,13 +28,13 @@ describe('Shared Telemetry Helpers', () => {
describe('sendTelemetry', () => {
it('successfully calls the server-side telemetry endpoint', () => {
sendTelemetry({
http: httpMock,
http: mockHttpValues.http,
product: 'enterprise_search',
action: 'viewed',
metric: 'setup_guide',
});
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', {
expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', {
headers,
body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}',
});
@ -50,33 +51,27 @@ describe('Shared Telemetry Helpers', () => {
describe('React component helpers', () => {
it('SendEnterpriseSearchTelemetry component', () => {
mountWithKibanaContext(<SendEnterpriseSearchTelemetry action="viewed" metric="page" />, {
http: httpMock,
});
shallow(<SendEnterpriseSearchTelemetry action="viewed" metric="page" />);
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', {
expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', {
headers,
body: '{"product":"enterprise_search","action":"viewed","metric":"page"}',
});
});
it('SendAppSearchTelemetry component', () => {
mountWithKibanaContext(<SendAppSearchTelemetry action="clicked" metric="button" />, {
http: httpMock,
});
shallow(<SendAppSearchTelemetry action="clicked" metric="button" />);
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', {
expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', {
headers,
body: '{"product":"app_search","action":"clicked","metric":"button"}',
});
});
it('SendWorkplaceSearchTelemetry component', () => {
mountWithKibanaContext(<SendWorkplaceSearchTelemetry action="error" metric="not_found" />, {
http: httpMock,
});
shallow(<SendWorkplaceSearchTelemetry action="error" metric="not_found" />);
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', {
expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', {
headers,
body: '{"product":"workplace_search","action":"error","metric":"not_found"}',
});

View file

@ -4,11 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useValues } from 'kea';
import { HttpSetup } from 'src/core/public';
import { JSON_HEADER as headers } from '../../../../common/constants';
import { KibanaContext, IKibanaContext } from '../../index';
import { HttpLogic } from '../http';
interface ISendTelemetryProps {
action: 'viewed' | 'error' | 'clicked';
@ -41,7 +42,7 @@ export const SendEnterpriseSearchTelemetry: React.FC<ISendTelemetryProps> = ({
action,
metric,
}) => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const { http } = useValues(HttpLogic);
useEffect(() => {
sendTelemetry({ http, action, metric, product: 'enterprise_search' });
@ -51,7 +52,7 @@ export const SendEnterpriseSearchTelemetry: React.FC<ISendTelemetryProps> = ({
};
export const SendAppSearchTelemetry: React.FC<ISendTelemetryProps> = ({ action, metric }) => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const { http } = useValues(HttpLogic);
useEffect(() => {
sendTelemetry({ http, action, metric, product: 'app_search' });
@ -61,7 +62,7 @@ export const SendAppSearchTelemetry: React.FC<ISendTelemetryProps> = ({ action,
};
export const SendWorkplaceSearchTelemetry: React.FC<ISendTelemetryProps> = ({ action, metric }) => {
const { http } = useContext(KibanaContext) as IKibanaContext;
const { http } = useValues(HttpLogic);
useEffect(() => {
sendTelemetry({ http, action, metric, product: 'workplace_search' });

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import '../../../../__mocks__/kea.mock';
import '../../../../__mocks__/shallow_usecontext.mock';
import React from 'react';

View file

@ -5,17 +5,19 @@
*/
import React, { useContext } from 'react';
import { useValues } from 'kea';
import { EuiButton, EuiButtonProps, EuiLinkProps } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { sendTelemetry } from '../../../../shared/telemetry';
import { HttpLogic } from '../../../../shared/http';
import { KibanaContext, IKibanaContext } from '../../../../index';
export const ProductButton: React.FC = () => {
const { http } = useValues(HttpLogic);
const {
externalUrl: { getWorkplaceSearchUrl },
http,
} = useContext(KibanaContext) as IKibanaContext;
const buttonProps = {

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import '../../../__mocks__/kea.mock';
import '../../../__mocks__/shallow_usecontext.mock';
import React from 'react';

View file

@ -5,6 +5,7 @@
*/
import React, { useContext } from 'react';
import { useValues } from 'kea';
import {
EuiButton,
@ -17,7 +18,9 @@ import {
EuiButtonEmptyProps,
EuiLinkProps,
} from '@elastic/eui';
import { sendTelemetry } from '../../../shared/telemetry';
import { HttpLogic } from '../../../shared/http';
import { KibanaContext, IKibanaContext } from '../../../index';
interface IOnboardingCardProps {
@ -39,8 +42,8 @@ export const OnboardingCard: React.FC<IOnboardingCardProps> = ({
actionPath,
complete,
}) => {
const { http } = useValues(HttpLogic);
const {
http,
externalUrl: { getWorkplaceSearchUrl },
} = useContext(KibanaContext) as IKibanaContext;

View file

@ -23,6 +23,7 @@ import {
} from '@elastic/eui';
import sharedSourcesIcon from '../../components/shared/assets/share_circle.svg';
import { sendTelemetry } from '../../../shared/telemetry';
import { HttpLogic } from '../../../shared/http';
import { KibanaContext, IKibanaContext } from '../../../index';
import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes';
@ -135,8 +136,8 @@ export const OnboardingSteps: React.FC = () => {
};
export const OrgNameOnboarding: React.FC = () => {
const { http } = useValues(HttpLogic);
const {
http,
externalUrl: { getWorkplaceSearchUrl },
} = useContext(KibanaContext) as IKibanaContext;

View file

@ -14,6 +14,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { ContentSection } from '../../components/shared/content_section';
import { sendTelemetry } from '../../../shared/telemetry';
import { HttpLogic } from '../../../shared/http';
import { KibanaContext, IKibanaContext } from '../../../index';
import { SOURCE_DETAILS_PATH, getContentSourcePath } from '../../routes';
@ -93,8 +94,8 @@ export const RecentActivityItem: React.FC<IFeedActivity> = ({
timestamp,
sourceId,
}) => {
const { http } = useValues(HttpLogic);
const {
http,
externalUrl: { getWorkplaceSearchUrl },
} = useContext(KibanaContext) as IKibanaContext;