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 Jul 2, 2020
1 parent fd30d3b commit 699f2b5
Show file tree
Hide file tree
Showing 16 changed files with 345 additions and 136 deletions.
1 change: 1 addition & 0 deletions x-pack/.i18nrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"xpack.data": "plugins/data_enhanced",
"xpack.embeddableEnhanced": "plugins/embeddable_enhanced",
"xpack.endpoint": "plugins/endpoint",
"xpack.enterpriseSearch": "plugins/enterprise_search",
"xpack.features": "plugins/features",
"xpack.fileUpload": "plugins/file_upload",
"xpack.globalSearch": ["plugins/global_search"],
Expand Down
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.enterpriseSearch.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.enterpriseSearch.appSearch.emptyState.description1"
defaultMessage="Looks like you don’t have any App Search engines."
/>
<br />
<FormattedMessage
id="xpack.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.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.enterpriseSearch.appSearch.enginesOverview.metaEngines"
defaultMessage="Meta Engines"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
Expand Down
Loading

0 comments on commit 699f2b5

Please sign in to comment.