Skip to content

Commit

Permalink
Add i18n formatted messages / translations (#13)
Browse files Browse the repository at this point in the history
* Add i18n provider and formatted/i18n translated messages

* Update tests to account for new I18nProvider context + FormattedMessage components

- Add new mountWithContext helper that provides all contexts+providers used in top-level app
- Add new shallowWithIntl helper for shallow() components that dive into FormattedMessage

* Format i18n dates and numbers

+ update some mock tests to not throw react-intl invalid date messages
  • Loading branch information
Constance authored and cee-chen committed Jun 9, 2020
1 parent 43fcb2f commit 8b768ef
Show file tree
Hide file tree
Showing 15 changed files with 324 additions and 137 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export { mockHistory } from './react_router_history.mock';
export { mockKibanaContext } from './kibana_context.mock';
export { mockLicenseContext } from './license_context.mock';
export { mountWithKibanaContext } from './mount_with_context.mock';
export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock';
export { shallowWithIntl } from './shallow_with_i18n.mock';

// Note: shallow_usecontext must be imported directly as a file
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,43 @@
import React from 'react';
import { mount } from 'enzyme';

import { I18nProvider } from '@kbn/i18n/react';
import { KibanaContext } from '../';
import { mockKibanaContext } from './kibana_context.mock';
import { LicenseContext } from '../shared/licensing';
import { mockLicenseContext } from './license_context.mock';

/**
* This helper mounts a component with a set of default KibanaContext,
* while also allowing custom context to be passed in via a second arg
* This helper mounts a component with all the contexts/providers used
* by the production app, while allowing custom context to be
* passed in via a second arg
*
* Example usage:
*
* const wrapper = mountWithKibanaContext(<Component />, { enterpriseSearchUrl: 'someOverride' });
* const wrapper = mountWithContext(<Component />, { enterpriseSearchUrl: 'someOverride', license: {} });
*/
export const mountWithKibanaContext = (node, contextProps) => {
export const mountWithContext = (children, context) => {
return mount(
<KibanaContext.Provider value={{ ...mockKibanaContext, ...contextProps }}>
{node}
<I18nProvider>
<KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}>
<LicenseContext.Provider value={{ ...mockLicenseContext, ...context }}>
{children}
</LicenseContext.Provider>
</KibanaContext.Provider>
</I18nProvider>
);
};

/**
* This helper mounts a component with just the default KibanaContext -
* useful for isolated / helper components that only need this context
*
* Same usage/override functionality as mountWithContext
*/
export const mountWithKibanaContext = (children, context) => {
return mount(
<KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}>
{children}
</KibanaContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { I18nProvider } from '@kbn/i18n/react';
import { IntlProvider } from 'react-intl';

const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {});
const { intl } = intlProvider.getChildContext();

/**
* This helper shallow wraps a component with react-intl's <I18nProvider> which
* fixes "Could not find required `intl` object" console errors when running tests
*
* Example usage (should be the same as shallow()):
*
* const wrapper = shallowWithIntl(<Component />);
*/
export const shallowWithIntl = children => {
return shallow(<I18nProvider>{children}</I18nProvider>, {
context: { intl },
childContextTypes: { intl },
})
.childAt(0)
.dive()
.shallow();
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import React, { useContext } from 'react';
import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';

import { sendTelemetry } from '../../../shared/telemetry';
import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
Expand Down Expand Up @@ -39,17 +40,34 @@ export const EmptyState: React.FC<> = () => {
<EuiPageContent>
<EuiEmptyPrompt
iconType="eyeClosed"
title={<h2>There’s nothing here yet</h2>}
title={
<h2>
<FormattedMessage
id="xpack.appSearch.emptyState.title"
defaultMessage="There’s nothing here yet"
/>
</h2>
}
titleSize="l"
body={
<p>
Looks like you don’t have any App Search engines.
<br /> Let’s create your first one now.
<FormattedMessage
id="xpack.appSearch.emptyState.description1"
defaultMessage="Looks like you don’t have any App Search engines."
/>
<br />
<FormattedMessage
id="xpack.appSearch.emptyState.description2"
defaultMessage="Let’s create your first one now."
/>
</p>
}
actions={
<EuiButton iconType="popout" fill {...buttonProps}>
Create your first Engine
<FormattedMessage
id="xpack.appSearch.emptyState.createFirstEngineCta"
defaultMessage="Create your first Engine"
/>
</EuiButton>
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import '../../../__mocks__/shallow_usecontext.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiButton, EuiCode, EuiLoadingContent } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { shallowWithIntl } from '../../../__mocks__';

jest.mock('../../utils/get_username', () => ({ getUserName: jest.fn() }));
import { getUserName } from '../../utils/get_username';
Expand All @@ -24,38 +26,36 @@ import { ErrorState, NoUserState, EmptyState, LoadingState } from './';
describe('ErrorState', () => {
it('renders', () => {
const wrapper = shallow(<ErrorState />);
const prompt = wrapper.find(EuiEmptyPrompt);

expect(prompt).toHaveLength(1);
expect(prompt.prop('title')).toEqual(<h2>Cannot connect to App Search</h2>);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});
});

describe('NoUserState', () => {
it('renders', () => {
const wrapper = shallow(<NoUserState />);
const prompt = wrapper.find(EuiEmptyPrompt);

expect(prompt).toHaveLength(1);
expect(prompt.prop('title')).toEqual(<h2>Cannot find App Search account</h2>);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});

it('renders with username', () => {
getUserName.mockImplementationOnce(() => 'dolores-abernathy');
const wrapper = shallow(<NoUserState />);
const wrapper = shallowWithIntl(<NoUserState />);
const prompt = wrapper.find(EuiEmptyPrompt).dive();
const description1 = prompt
.find(FormattedMessage)
.at(1)
.dive();

expect(prompt.find(EuiCode).prop('children')).toContain('dolores-abernathy');
expect(description1.find(EuiCode).prop('children')).toContain('dolores-abernathy');
});
});

describe('EmptyState', () => {
it('renders', () => {
const wrapper = shallow(<EmptyState />);
const prompt = wrapper.find(EuiEmptyPrompt);

expect(prompt).toHaveLength(1);
expect(prompt.prop('title')).toEqual(<h2>There’s nothing here yet</h2>);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});

it('sends telemetry on create first engine click', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import React, { useContext } from 'react';
import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';

import { EuiButton } from '../../../shared/react_router_helpers';
import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
Expand All @@ -29,23 +30,43 @@ export const ErrorState: ReactFC<> = () => {
<EuiEmptyPrompt
iconType="alert"
iconColor="danger"
title={<h2>Cannot connect to App Search</h2>}
title={
<h2>
<FormattedMessage
id="xpack.appSearch.errorConnectingState.title"
defaultMessage="Cannot connect to App Search"
/>
</h2>
}
titleSize="l"
body={
<>
<p>
We cannot connect to the App Search instance at the configured host URL:{' '}
<EuiCode>{enterpriseSearchUrl}</EuiCode>
<FormattedMessage
id="xpack.appSearch.errorConnectingState.description1"
defaultMessage="We cannot connect to the App Search instance at the configured host URL: {enterpriseSearchUrl}"
values={{
enterpriseSearchUrl: <EuiCode>{enterpriseSearchUrl}</EuiCode>,
}}
/>
</p>
<p>
Please ensure your App Search host URL is configured correctly within{' '}
<EuiCode>config/kibana.yml</EuiCode>.
<FormattedMessage
id="xpack.appSearch.errorConnectingState.description2"
defaultMessage="Please ensure your App Search host URL is configured correctly within {configFile}."
values={{
configFile: <EuiCode>config/kibana.yml</EuiCode>,
}}
/>
</p>
</>
}
actions={
<EuiButton iconType="help" fill to="/setup_guide">
Review the setup guide
<FormattedMessage
id="xpack.appSearch.errorConnectingState.setupGuideCta"
defaultMessage="Review the setup guide"
/>
</EuiButton>
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import React from 'react';
import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';

import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
Expand All @@ -27,21 +28,31 @@ export const NoUserState: React.FC<> = () => {
<EuiPageContent>
<EuiEmptyPrompt
iconType="lock"
title={<h2>Cannot find App Search account</h2>}
title={
<h2>
<FormattedMessage
id="xpack.appSearch.noUserState.title"
defaultMessage="Cannot find App Search account"
/>
</h2>
}
titleSize="l"
body={
<>
<p>
We cannot find an App Search account matching your username
{username && (
<>
: <EuiCode>{username}</EuiCode>
</>
)}
.
<FormattedMessage
id="xpack.appSearch.noUserState.description1"
defaultMessage="We cannot find an App Search account matching your username{username}."
values={{
username: username ? <EuiCode>{username}</EuiCode> : '',
}}
/>
</p>
<p>
Please contact your App Search administrator to request an invite for that user.
<FormattedMessage
id="xpack.appSearch.noUserState.description2"
defaultMessage="Please contact your App Search administrator to request an invite for that user."
/>
</p>
</>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { render } from 'enzyme';

import { I18nProvider } from '@kbn/i18n/react';
import { KibanaContext } from '../../../';
import { LicenseContext } from '../../../shared/licensing';
import { mountWithKibanaContext, mockKibanaContext } from '../../../__mocks__';
import { mountWithContext, mockKibanaContext } from '../../../__mocks__';

import { EmptyState, ErrorState, NoUserState } from '../empty_states';
import { EngineTable } from './engine_table';
Expand All @@ -23,12 +24,15 @@ describe('EngineOverview', () => {
describe('non-happy-path states', () => {
it('isLoading', () => {
// We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect)
// TODO: Consider pulling this out to a renderWithContext mock/helper
const wrapper = render(
<KibanaContext.Provider value={{ http: {} }}>
<LicenseContext.Provider value={{ license: {} }}>
<EngineOverview />
</LicenseContext.Provider>
</KibanaContext.Provider>
<I18nProvider>
<KibanaContext.Provider value={{ http: {} }}>
<LicenseContext.Provider value={{ license: {} }}>
<EngineOverview />
</LicenseContext.Provider>
</KibanaContext.Provider>
</I18nProvider>
);

// render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly
Expand Down Expand Up @@ -66,7 +70,7 @@ describe('EngineOverview', () => {
results: [
{
name: 'hello-world',
created_at: 'somedate',
created_at: 'Fri, 1 Jan 1970 12:00:00 +0000',
document_count: 50,
field_count: 10,
},
Expand Down Expand Up @@ -164,12 +168,7 @@ describe('EngineOverview', () => {
// TBH, I don't fully understand why since Enzyme's mount is supposed to
// have act() baked in - could be because of the wrapping context provider?
await act(async () => {
wrapper = mountWithKibanaContext(
<LicenseContext.Provider value={{ license }}>
<EngineOverview />
</LicenseContext.Provider>,
{ http: httpMock }
);
wrapper = mountWithContext(<EngineOverview />, { http: httpMock, license });
});
wrapper.update(); // This seems to be required for the DOM to actually update

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';

import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
Expand Down Expand Up @@ -100,7 +101,10 @@ export const EngineOverview: ReactFC<> = () => {
<EuiTitle size="s">
<h2>
<img src={EnginesIcon} alt="" className="engine-icon" />
Engines
<FormattedMessage
id="xpack.appSearch.enginesOverview.engines"
defaultMessage="Engines"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
Expand All @@ -122,7 +126,10 @@ export const EngineOverview: ReactFC<> = () => {
<EuiTitle size="s">
<h2>
<img src={MetaEnginesIcon} alt="" className="engine-icon" />
Meta Engines
<FormattedMessage
id="xpack.appSearch.enginesOverview.metaEngines"
defaultMessage="Meta Engines"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
Expand Down
Loading

0 comments on commit 8b768ef

Please sign in to comment.