diff --git a/src/components/form/super_select/super_select_control.js b/src/components/form/super_select/super_select_control.js
index a9130e4ea78..c98a27a04e3 100644
--- a/src/components/form/super_select/super_select_control.js
+++ b/src/components/form/super_select/super_select_control.js
@@ -7,6 +7,7 @@ import makeId from '../form_row/make_id';
import {
EuiFormControlLayout,
} from '../form_control_layout';
+import { EuiI18n } from '../../i18n';
export const EuiSuperSelectControl = ({
className,
@@ -37,7 +38,7 @@ export const EuiSuperSelectControl = ({
selectDefaultValue = defaultValue || '';
}
- let selectedValue;
+ let selectedValue = '';
if (value) {
const selectedOption = options.find(option => option.value === value);
selectedValue = selectedOption.inputDisplay;
@@ -73,7 +74,11 @@ export const EuiSuperSelectControl = ({
*/}
- Select an option: {selectedValue}, is selected
+
diff --git a/src/components/i18n/__snapshots__/i18n.test.tsx.snap b/src/components/i18n/__snapshots__/i18n.test.tsx.snap
new file mode 100644
index 00000000000..2892971d76e
--- /dev/null
+++ b/src/components/i18n/__snapshots__/i18n.test.tsx.snap
@@ -0,0 +1,355 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EuiI18n default rendering render prop with multiple tokens renders render prop result to the dom 1`] = `
+
+
+ This is the first basic string.
+
+ This is the second basic string.
+
+
+`;
+
+exports[`EuiI18n default rendering render prop with single token calls a function and renders render prop result to the dom 1`] = `
+
+ Here's something neat: This is a callback with values.
+
+`;
+
+exports[`EuiI18n default rendering render prop with single token renders render prop result to the dom 1`] = `
+
+ A nifty thing: This is a basic string.
+
+`;
+
+exports[`EuiI18n default rendering render prop with single token renders render prop result with placeholders to the dom 1`] = `
+
+ Here's something cool: This is a string with values.
+
+`;
+
+exports[`EuiI18n default rendering rendering to dom calls a function and renders the result to the dom 1`] = `
+
+ This is a callback with values.
+
+`;
+
+exports[`EuiI18n default rendering rendering to dom renders a basic string to the dom 1`] = `
+
+ This is a basic string.
+
+`;
+
+exports[`EuiI18n default rendering rendering to dom renders a string with placeholders to the dom 1`] = `
+
+ This is a string with values.
+
+`;
+
+exports[`EuiI18n reading values from context render prop with multiple tokens renders mapped render prop result to the dom 1`] = `
+
+
+
+ This is the first mapped value.
+
+ This is the second mapped value.
+
+
+
+`;
+
+exports[`EuiI18n reading values from context render prop with single token calls a mapped function and renders render prop result to the dom 1`] = `
+
+
+ Here's something neat: This is a callback with values.
+
+
+`;
+
+exports[`EuiI18n reading values from context render prop with single token renders mapped render prop result to the dom 1`] = `
+
+
+ A nifty thing: An overridden string.
+
+
+`;
+
+exports[`EuiI18n reading values from context render prop with single token renders mapped render prop result with placeholders to the dom 1`] = `
+
+
+ Here's something cool: An overridden string with values.
+
+
+`;
+
+exports[`EuiI18n reading values from context rendering to dom calls a mapped function and renders the result to the dom 1`] = `
+
+
+ This is a mapped callback with values.
+
+
+`;
+
+exports[`EuiI18n reading values from context rendering to dom renders a mapped basic string to the dom 1`] = `
+
+
+ An overridden string.
+
+
+`;
+
+exports[`EuiI18n reading values from context rendering to dom renders a mapped string with placeholders to the dom 1`] = `
+
+
+ An overridden string with values.
+
+
+`;
diff --git a/src/components/i18n/i18n.test.tsx b/src/components/i18n/i18n.test.tsx
new file mode 100644
index 00000000000..c38567866fa
--- /dev/null
+++ b/src/components/i18n/i18n.test.tsx
@@ -0,0 +1,228 @@
+import React, { ReactChild } from 'react';
+import { mount } from 'enzyme';
+import { EuiContext } from '../context';
+import { EuiI18n } from './i18n';
+
+describe('EuiI18n', () => {
+ describe('default rendering', () => {
+ describe('rendering to dom', () => {
+ it('renders a basic string to the dom', () => {
+ const component = mount(
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('renders a string with placeholders to the dom', () => {
+ const component = mount(
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('calls a function and renders the result to the dom', () => {
+ const values = { type: 'callback', special: 'values' };
+ const renderCallback = jest.fn(({ type, special }) =>
+ `This is a ${type} with ${special}.`
+ );
+ const component = mount(
+
+ );
+ expect(component).toMatchSnapshot();
+
+ expect(renderCallback).toHaveBeenCalledWith(values);
+ });
+ });
+
+ describe('render prop with single token', () => {
+ it('renders render prop result to the dom', () => {
+ const component = mount(
+
+ {(result: ReactChild) => `A nifty thing: ${result}`}
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('renders render prop result with placeholders to the dom', () => {
+ const component = mount(
+
+ {(result: ReactChild) => `Here's something cool: ${result}`}
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('calls a function and renders render prop result to the dom', () => {
+ const values = { type: 'callback', special: 'values' };
+ const renderCallback = jest.fn(({ type, special }) =>
+ `This is a ${type} with ${special}.`
+ );
+ const component = mount(
+
+ {(result: ReactChild) => `Here's something neat: ${result}`}
+
+ );
+ expect(component).toMatchSnapshot();
+
+ expect(renderCallback).toHaveBeenCalledWith(values);
+ });
+ });
+
+ describe('render prop with multiple tokens', () => {
+ it('renders render prop result to the dom', () => {
+ const component = mount(
+
+ {([one, two]: ReactChild[]) => {one} {two}
}
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('reading values from context', () => {
+ describe('rendering to dom', () => {
+ it('renders a mapped basic string to the dom', () => {
+ const component = mount(
+
+
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('renders a mapped string with placeholders to the dom', () => {
+ const component = mount(
+
+
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('calls a mapped function and renders the result to the dom', () => {
+ const values = { type: 'callback', special: 'values' };
+ const renderCallback = jest.fn(({ type, special }) =>
+ `This is a mapped ${type} with ${special}.`
+ );
+ const component = mount(
+
+ ''}
+ values={values}
+ />
+
+ );
+ expect(component).toMatchSnapshot();
+
+ expect(renderCallback).toHaveBeenCalledWith(values);
+ });
+ });
+
+ describe('render prop with single token', () => {
+ it('renders mapped render prop result to the dom', () => {
+ const component = mount(
+
+
+ {(result: ReactChild) => `A nifty thing: ${result}`}
+
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('renders mapped render prop result with placeholders to the dom', () => {
+ const component = mount(
+
+
+ {(result: ReactChild) => `Here's something cool: ${result}`}
+
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('calls a mapped function and renders render prop result to the dom', () => {
+ const values = { type: 'callback', special: 'values' };
+ const renderCallback = jest.fn(({ type, special }) =>
+ `This is a ${type} with ${special}.`
+ );
+ const component = mount(
+
+
+ {(result: ReactChild) => `Here's something neat: ${result}`}
+
+
+ );
+ expect(component).toMatchSnapshot();
+
+ expect(renderCallback).toHaveBeenCalledWith(values);
+ });
+ });
+
+ describe('render prop with multiple tokens', () => {
+ it('renders mapped render prop result to the dom', () => {
+ const component = mount(
+
+
+ {([one, two]: ReactChild[]) => {one} {two}
}
+
+
+ );
+ expect(component).toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/src/components/i18n/i18n.tsx b/src/components/i18n/i18n.tsx
index 4530104d598..885c4e176df 100644
--- a/src/components/i18n/i18n.tsx
+++ b/src/components/i18n/i18n.tsx
@@ -1,35 +1,64 @@
-import React, { ReactChild, ReactElement } from 'react';
+import React, { Fragment, ReactChild, SFC } from 'react';
import { EuiI18nConsumer } from '../context';
import { ExclusiveUnion } from '../common';
-import { I18nShape } from '../context/context';
+import { I18nShape, Renderable, RenderableValues } from '../context/context';
+import { processStringToChildren } from './i18n_util';
-//
-//
{(foo) => foo
}
-//
{([foo, bar]) => {foo}, {bar}
+function throwError(): never {
+ throw new Error('asdf');
+}
+
+function lookupToken
(
+ token: string,
+ i18nMapping: I18nShape['mapping'],
+ valueDefault: Renderable,
+ values?: I18nTokenShape['values']
+): ReactChild {
+ const renderable = (i18nMapping && i18nMapping[token]) || valueDefault;
+ if (typeof renderable === 'function') {
+ if (values === undefined) {
+ return throwError();
+ } else {
+ return renderable(values);
+ }
+ } else if (values === undefined || typeof renderable !== 'string') {
+ return renderable;
+ }
+
+ const children = processStringToChildren(renderable, values);
+ if (typeof children === 'string') {
+ return children;
+ }
-function lookupToken(token: string, i18nMapping: I18nShape['mapping'], valueDefault: ReactChild) {
- return (i18nMapping && i18nMapping[token]) || valueDefault;
+ const Component: SFC = () => {
+ return {children};
+ };
+ return React.createElement(Component, values);
}
-interface I18nTokenShape {
+interface I18nTokenShape {
token: string;
- default: ReactChild;
- children?: (x: ReactChild) => ReactElement;
+ default: Renderable;
+ children?: (x: ReactChild) => ReactChild;
+ values?: T;
}
interface I18nTokensShape {
tokens: string[];
defaults: ReactChild[];
- children: (x: ReactChild[]) => ReactElement;
+ children: (x: ReactChild[]) => ReactChild;
}
-type EuiI18nProps = ExclusiveUnion;
+type EuiI18nProps = ExclusiveUnion, I18nTokensShape>;
-function hasTokens(x: EuiI18nProps): x is I18nTokensShape {
+function hasTokens(x: EuiI18nProps): x is I18nTokensShape {
return x.tokens != null;
}
-const EuiI18n: React.SFC = (props) => (
+// Must use the generics
+// If instead typed with React.SFC there isn't feedback given back to the dev
+// when using a `values` object with a renderer callback.
+const EuiI18n = (props: EuiI18nProps) => (
{
(i18nConfig) => {
@@ -38,7 +67,7 @@ const EuiI18n: React.SFC = (props) => (
return props.children(props.tokens.map((token, idx) => lookupToken(token, mapping, props.defaults[idx])));
}
- const tokenValue = lookupToken(props.token, mapping, props.default);
+ const tokenValue = lookupToken(props.token, mapping, props.default, props.values);
if (props.children) {
return props.children(tokenValue);
} else {
diff --git a/src/components/i18n/i18n_util.test.tsx b/src/components/i18n/i18n_util.test.tsx
new file mode 100644
index 00000000000..513a0c9de2d
--- /dev/null
+++ b/src/components/i18n/i18n_util.test.tsx
@@ -0,0 +1,31 @@
+import { processStringToChildren } from './i18n_util';
+
+describe('i18n_util', () => {
+ describe('processStringToChildren', () => {
+ it('returns a basic string as is', () => {
+ const message = 'This is a test message.';
+ expect(processStringToChildren(message, {})).toEqual(message);
+ });
+
+ it('replaces placeholders with values', () => {
+ expect(processStringToChildren(
+ '{greeting}, {name}',
+ { greeting: 'Hello', name: 'John' }
+ )).toEqual('Hello, John');
+ });
+
+ describe('escape characters', () => {
+ it('backslash escapes opening and closing braces', () => {
+ expect(processStringToChildren(
+ '{greeting}, \\{{name}\\}',
+ { greeting: 'Hello', name: 'John' }
+ )).toEqual('Hello, {John}');
+ });
+
+ it('backslash does not escape any other characters', () => {
+ const message = 'Thi\\s is\\ a test \\message\\.';
+ expect(processStringToChildren(message, {})).toEqual(message);
+ });
+ });
+ });
+});
diff --git a/src/components/i18n/i18n_util.tsx b/src/components/i18n/i18n_util.tsx
new file mode 100644
index 00000000000..3bae35eeab4
--- /dev/null
+++ b/src/components/i18n/i18n_util.tsx
@@ -0,0 +1,98 @@
+import { cloneElement, ReactChild } from 'react';
+import { isBoolean, isString, isNumber } from '../../services/predicate/lodash_predicates';
+import {isElement} from 'react-is';
+import { RenderableValues } from '../context/context';
+
+function isPrimitive(value: ReactChild) {
+ return isBoolean(value) || isString(value) || isNumber(value);
+}
+
+type Child = string | {propName: string} | ReactChild | undefined;
+
+function hasPropName(child: Child): child is ({propName: string}) {
+ return typeof child === 'object' && child.hasOwnProperty('propName');
+}
+
+/**
+ * Replaces placeholder values in `input` with their matching value in `values`
+ * e.g. input:'Hello, {name}' will replace `{name}` with `values[name]`
+ * @param {string} input
+ * @param {RenderableValues} values
+ * @returns {string | React.ReactChild[]}
+ */
+export function processStringToChildren(input: string, values: RenderableValues): string | ReactChild[] {
+ const children: ReactChild[] = [];
+
+ let child: Child;
+
+ function appendCharToChild(char: string) {
+ if (child === undefined) {
+ // starting a new string literal
+ child = char;
+ } else if (typeof child === 'string') {
+ // existing string literal
+ child = child + char;
+ } else if (hasPropName(child)) {
+ // adding to the propName of a values lookup
+ child.propName = child.propName + char;
+ }
+ }
+
+ function appendValueToChildren(value: Child) {
+ if (value === undefined) {
+ return;
+ } else if (isElement(value)) {
+ // an array with any ReactElements will be kept as an array
+ // so they need to be assigned a key
+ children.push(cloneElement(
+ value,
+ { key: children.length }
+ ));
+ } else if (hasPropName(value)) {
+ // this won't be called, propName children are converted to a ReactChild before calling this
+ } else {
+ // everything else can go straight in
+ children.push(value);
+ }
+ }
+
+ // if we don't encounter a non-primitive
+ // then `children` can be concatenated together at the end
+ let encounteredNonPrimitive = false;
+ // tslint:disable-next-line:prefer-for-of
+ for (let i = 0; i < input.length; i++) {
+ const char = input[i];
+
+ if (char === '\\') {
+ // peek at the next character to know if this is an escape
+ const nextChar = input[i + 1];
+ let charToAdd = char; // if this isn't an escape sequence then we will add the backslash
+
+ if (nextChar === '{' || nextChar === '}') {
+ // escaping a brace
+ i += 1; // advance passed the brace
+ charToAdd = input[i];
+ }
+ appendCharToChild(charToAdd);
+ } else if (char === '{') {
+ appendValueToChildren(child);
+ child = {propName: ''};
+ } else if (char === '}') {
+ const propName = (child as {propName: string}).propName;
+ if (!values.hasOwnProperty(propName)) {
+ throw new Error(`Key "${propName}" not found in ${JSON.stringify(values, null, 2)}`);
+ }
+ const propValue = values[propName];
+ encounteredNonPrimitive = encounteredNonPrimitive || !(isPrimitive(propValue));
+ appendValueToChildren(propValue);
+ child = undefined;
+ } else {
+ appendCharToChild(char);
+ }
+ }
+
+ // include any remaining child value
+ appendValueToChildren(child);
+
+ return encounteredNonPrimitive ? children : children.join('');
+}
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index 8988d4c0132..a9ec4e21f09 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -19,6 +19,7 @@ import { EuiPortal } from '../portal';
import { EuiMutationObserver } from '../mutation_observer';
import { findPopoverPosition, getElementZIndex } from '../../services/popover/popover_positioning';
+import { EuiI18n } from '../i18n';
const anchorPositionToPopoverPositionMap = {
'up': 'top',
@@ -430,7 +431,9 @@ export class EuiPopover extends Component {
if (ownFocus) {
focusTrapScreenReaderText = (
- You are in a popup. To exit this popup, hit escape.
+
+
+
);
}
diff --git a/yarn.lock b/yarn.lock
index a18daba0bdf..93d55fcceec 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -894,6 +894,13 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.3.tgz#bef071852dca2a2dbb65fecdb7bfb30cedae2de2"
integrity sha512-sfjHrNF4zWRv3fJUGyZW46wVxhYJ/GeWIPdKxbnLIhY3bWR0Ncl2kIhZI7rpjY9KtUQAkDP8jWEmaGQGFFvruA==
+"@types/react-is@~16.3.0":
+ version "16.3.1"
+ resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-16.3.1.tgz#f3e1dee9d0eb58c049825540cb061b5588022a9e"
+ integrity sha512-evXsFH7q8C1nGUq7XDm+IHqErVsNatfpB+fkmnhQHt1HxZV7VvFkuEmSV/xNHZrf2au91KsinMmUmFBbtAL1bQ==
+ dependencies:
+ "@types/react" "*"
+
"@types/react-virtualized@^9.18.6":
version "9.18.6"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.18.6.tgz#d5c559bd003a6c58ba9e20d6cda0dde0342f59af"
@@ -11211,6 +11218,11 @@ react-is@^16.3.2, react-is@^16.6.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.7.0.tgz#c1bd21c64f1f1364c6f70695ec02d69392f41bfa"
integrity sha512-Z0VRQdF4NPDoI0tsXVMLkJLiwEBa+RP66g0xDHxgxysxSoCUccSten4RTF/UFvZF1dZvZ9Zu1sx+MDXwcOR34g==
+react-is@~16.3.0:
+ version "16.3.2"
+ resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.3.2.tgz#f4d3d0e2f5fbb6ac46450641eb2e25bf05d36b22"
+ integrity sha512-ybEM7YOr4yBgFd6w8dJqwxegqZGJNBZl6U27HnGKuTZmDvVrD5quWOK/wAnMywiZzW+Qsk+l4X2c70+thp/A8Q==
+
react-motion@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316"