From a2338d9eede79ef35463d68bac7ddb3acab5fd62 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Wed, 15 Jul 2020 13:31:46 -0600 Subject: [PATCH 1/7] Added useEuiI18n hook --- src/components/context/context.tsx | 2 +- .../i18n/__snapshots__/i18n.test.tsx.snap | 110 +++++++++++++++ src/components/i18n/i18n.test.tsx | 133 +++++++++++++++++- src/components/i18n/i18n.tsx | 45 +++++- src/components/i18n/index.ts | 2 +- 5 files changed, 284 insertions(+), 8 deletions(-) diff --git a/src/components/context/context.tsx b/src/components/context/context.tsx index 02c55c75aab..abd0a6cd57a 100644 --- a/src/components/context/context.tsx +++ b/src/components/context/context.tsx @@ -48,4 +48,4 @@ const EuiContext: React.FunctionComponent = ({ children, }) => {children}; -export { EuiContext, EuiI18nConsumer }; +export { EuiContext, EuiI18nConsumer, I18nContext }; diff --git a/src/components/i18n/__snapshots__/i18n.test.tsx.snap b/src/components/i18n/__snapshots__/i18n.test.tsx.snap index 462989707db..f099e8aa0d2 100644 --- a/src/components/i18n/__snapshots__/i18n.test.tsx.snap +++ b/src/components/i18n/__snapshots__/i18n.test.tsx.snap @@ -151,6 +151,87 @@ exports[`EuiI18n default rendering rendering to dom renders when value is null 1 `; +exports[`EuiI18n mapped tokens handles multiple tokens 1`] = ` + + +

+ + first value + + + second value + +

+
+
+`; + +exports[`EuiI18n mapped tokens handles single token with values 1`] = ` + + +

+ In reverse order: aardvarks, then apples +

+
+
+`; + +exports[`EuiI18n mapped tokens handles single token without values 1`] = ` + + +

+ This is the mapped value. +

+
+
+`; + +exports[`EuiI18n mapped tokens mappingFunc calls the mapping function with the source string 1`] = ` + + +
+ PLACEHOLDER +
+
+
+`; + exports[`EuiI18n reading values from context mappingFunc calls the mapping function with the source string 1`] = ` `; + +exports[`EuiI18n useEuiI18n unmapped handles multiple tokens 1`] = ` + +

+ + the first placeholder + + + the second placeholder + +

+
+`; + +exports[`EuiI18n useEuiI18n unmapped handles single token with values 1`] = ` + +

+ first apples, then aardvarks +

+
+`; + +exports[`EuiI18n useEuiI18n unmapped handles single token without values 1`] = ` + +

+ placeholder +

+
+`; diff --git a/src/components/i18n/i18n.test.tsx b/src/components/i18n/i18n.test.tsx index b5ced702768..626280e32e5 100644 --- a/src/components/i18n/i18n.test.tsx +++ b/src/components/i18n/i18n.test.tsx @@ -20,7 +20,7 @@ import React, { ReactChild } from 'react'; import { mount } from 'enzyme'; import { EuiContext } from '../context'; -import { EuiI18n } from './i18n'; +import { EuiI18n, useEuiI18n } from './i18n'; /* eslint-disable local/i18n */ @@ -262,4 +262,135 @@ describe('EuiI18n', () => { }); }); }); + + describe('useEuiI18n', () => { + describe('unmapped', () => { + it('handles single token without values', () => { + const Component = () => { + const value = useEuiI18n('token', 'placeholder'); + return

{value}

; + }; + const component = mount(); + expect(component).toMatchSnapshot(); + }); + + it('handles single token with values', () => { + const Component = () => { + const value = useEuiI18n('myToken', 'first {first}, then {second}', { + first: 'apples', + second: 'aardvarks', + }); + return

{value}

; + }; + const component = mount(); + expect(component).toMatchSnapshot(); + }); + + it('handles multiple tokens', () => { + const Component = () => { + const [first, second] = useEuiI18n( + ['test1', 'test2'], + ['the first placeholder', 'the second placeholder'] + ); + return ( +

+ {first} + {second} +

+ ); + }; + const component = mount(); + expect(component).toMatchSnapshot(); + }); + }); + }); + + describe('mapped tokens', () => { + it('handles single token without values', () => { + const Component = () => { + const value = useEuiI18n('token', 'placeholder'); + return

{value}

; + }; + const component = mount( + + + + ); + expect(component).toMatchSnapshot(); + }); + + it('handles single token with values', () => { + const Component = () => { + const value = useEuiI18n('myToken', 'first {first}, then {second}', { + first: 'apples', + second: 'aardvarks', + }); + return

{value}

; + }; + const component = mount( + + + + ); + expect(component).toMatchSnapshot(); + }); + + it('handles multiple tokens', () => { + const Component = () => { + const [first, second] = useEuiI18n( + ['test1', 'test2'], + ['the first placeholder', 'the second placeholder'] + ); + return ( +

+ {first} + {second} +

+ ); + }; + const component = mount( + + + + ); + expect(component).toMatchSnapshot(); + }); + + describe('mappingFunc', () => { + it('calls the mapping function with the source string', () => { + const Component = () => { + const value = useEuiI18n('test1', 'placeholder'); + return
{value}
; + }; + const component = mount( + value.toUpperCase(), + }}> + + + ); + expect(component).toMatchSnapshot(); + }); + }); + }); }); diff --git a/src/components/i18n/i18n.tsx b/src/components/i18n/i18n.tsx index f37c541216a..f164dd8a8f0 100644 --- a/src/components/i18n/i18n.tsx +++ b/src/components/i18n/i18n.tsx @@ -17,10 +17,20 @@ * under the License. */ -import React, { Fragment, ReactChild, FunctionComponent } from 'react'; +import React, { + Fragment, + ReactChild, + FunctionComponent, + useContext, +} from 'react'; import { EuiI18nConsumer } from '../context'; import { ExclusiveUnion } from '../common'; -import { I18nShape, Renderable, RenderableValues } from '../context/context'; +import { + I18nContext, + I18nShape, + Renderable, + RenderableValues, +} from '../context/context'; import { processStringToChildren } from './i18n_util'; function errorOnMissingValues(token: string): never { @@ -93,7 +103,7 @@ type EuiI18nProps< DEFAULTS extends any[] > = ExclusiveUnion, I18nTokensShape>; -function hasTokens( +function isI18nTokensShape( x: EuiI18nProps ): x is I18nTokensShape { return x.tokens != null; @@ -112,7 +122,7 @@ const EuiI18n = < {i18nConfig => { const { mapping, mappingFunc } = i18nConfig; - if (hasTokens(props)) { + if (isI18nTokensShape(props)) { return props.children( props.tokens.map((token, idx) => lookupToken(token, mapping, props.defaults[idx], mappingFunc) @@ -136,4 +146,29 @@ const EuiI18n = < ); -export { EuiI18n }; +function useEuiI18n< + T extends {}, + DEFAULT extends Renderable, + DEFAULTS extends any[] +>(token: string, defaultValue: string, values?: T): string; +function useEuiI18n< + T extends {}, + DEFAULT extends Renderable, + DEFAULTS extends any[] +>(tokens: string[], defaultValues: string[]): string[]; +function useEuiI18n(...props: any[]) { + const i18nConfig = useContext(I18nContext); + const { mapping, mappingFunc } = i18nConfig; + + if (typeof props[0] === 'string') { + const [token, defaultValue, values] = props; + return lookupToken(token, mapping, defaultValue, mappingFunc, values); + } else { + const [tokens, defaultValues] = props as [string[], string[]]; + return tokens.map((token, idx) => + lookupToken(token, mapping, defaultValues[idx], mappingFunc) + ); + } +} + +export { EuiI18n, useEuiI18n }; diff --git a/src/components/i18n/index.ts b/src/components/i18n/index.ts index a2d16279155..8e06f382478 100644 --- a/src/components/i18n/index.ts +++ b/src/components/i18n/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { EuiI18n } from './i18n'; +export { EuiI18n, useEuiI18n } from './i18n'; export { EuiI18nNumber } from './i18n_number'; From 6757a726c45d504b5c1985f1dcabb26a29edb344 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 16 Jul 2020 10:57:36 -0600 Subject: [PATCH 2/7] Updated docs with useEuiI18n hook, added snippets --- src-docs/src/views/i18n/context.js | 104 ++++++++++----------- src-docs/src/views/i18n/i18n_attribute.js | 56 +++++++++++ src-docs/src/views/i18n/i18n_basic.js | 36 +++++-- src-docs/src/views/i18n/i18n_example.js | 80 ++++++++++++---- src-docs/src/views/i18n/i18n_multi.js | 72 ++++++++++---- src-docs/src/views/i18n/i18n_renderprop.js | 30 ------ src/components/index.js | 2 +- 7 files changed, 249 insertions(+), 131 deletions(-) create mode 100644 src-docs/src/views/i18n/i18n_attribute.js delete mode 100644 src-docs/src/views/i18n/i18n_renderprop.js diff --git a/src-docs/src/views/i18n/context.js b/src-docs/src/views/i18n/context.js index 43dd9b5597e..c62122a6c08 100644 --- a/src-docs/src/views/i18n/context.js +++ b/src-docs/src/views/i18n/context.js @@ -10,6 +10,7 @@ import { EuiSpacer, EuiI18n, EuiI18nNumber, + useEuiI18n, } from '../../../../src/components'; const mappings = { @@ -24,6 +25,38 @@ const mappings = { }, }; +const ContextConsumer = () => { + return ( +
+ + + + + + +

+ + +

+ + + + + + + + + + + {useEuiI18n('euiContext.action', 'Submit')} + +
+ ); +}; + export default () => { const [language, setLanguage] = useState('en'); @@ -33,61 +66,26 @@ export default () => { }; return ( - -
- - - setLanguage('en')}> - - - - - - setLanguage('fr')}> - - - - - - - - - - - - - -

- - -

- - + <> + + + setLanguage('en')}> + + + - - {([question, action, placeholder]) => ( - - - - + + setLanguage('fr')}> + + + + - + - {action} - - )} - -
-
+ + + + ); }; diff --git a/src-docs/src/views/i18n/i18n_attribute.js b/src-docs/src/views/i18n/i18n_attribute.js new file mode 100644 index 00000000000..0fda4c845d0 --- /dev/null +++ b/src-docs/src/views/i18n/i18n_attribute.js @@ -0,0 +1,56 @@ +import React from 'react'; + +import { + EuiCode, + EuiFieldText, + EuiI18n, + EuiFormRow, + EuiTitle, + useEuiI18n, + EuiSpacer, +} from '../../../../src/components'; + +export default () => { + return ( + <> + +

useEuiI18n used in an attribute

+
+

+ + This text field's placeholder reads from{' '} + euiI18nAttribute.placeholderName + + }> + + +

+ + + + +

EuiI18n used as a render prop

+
+ + {placeholderName => ( + + This text field's placeholder reads from{' '} + euiI18nAttribute.placeholderName + + }> + + + )} + + + ); +}; diff --git a/src-docs/src/views/i18n/i18n_basic.js b/src-docs/src/views/i18n/i18n_basic.js index b2d26318b7a..a488f0ca475 100644 --- a/src-docs/src/views/i18n/i18n_basic.js +++ b/src-docs/src/views/i18n/i18n_basic.js @@ -1,14 +1,36 @@ import React from 'react'; -import { EuiI18n } from '../../../../src/components'; +import { + EuiI18n, + EuiTitle, + EuiSpacer, + useEuiI18n, +} from '../../../../src/components'; export default () => { return ( -

- -

+ <> + +

Basic useEuiI18n usage

+
+

+ {useEuiI18n( + 'euiI18nBasic.basicexample', + 'This is the English copy that would be replaced by a translation defined by the i18n.basicexample token.' + )} +

+ + + + +

Basic EuiI18n usage

+
+

+ +

+ ); }; diff --git a/src-docs/src/views/i18n/i18n_example.js b/src-docs/src/views/i18n/i18n_example.js index 73961765101..34b0bcec61c 100644 --- a/src-docs/src/views/i18n/i18n_example.js +++ b/src-docs/src/views/i18n/i18n_example.js @@ -9,18 +9,51 @@ import { EuiCode, EuiI18n, EuiContext } from '../../../../src/components'; import I18nBasic from './i18n_basic'; const i18nBasicSource = require('!!raw-loader!./i18n_basic'); const i18nBasicHtml = renderToHtml(I18nBasic); +const basicSnippet = [ + `useEuiI18n('filename.token', 'default value') +`, + ` +`, +]; -import I18nRenderProp from './i18n_renderprop'; -const i18nRenderPropSource = require('!!raw-loader!./i18n_renderprop'); -const i18nRenderPropHtml = renderToHtml(I18nRenderProp); +import I18nAttribute from './i18n_attribute'; +const i18nAttributeSource = require('!!raw-loader!./i18n_attribute'); +const i18nAttributeHtml = renderToHtml(I18nAttribute); +const attributeSnippet = [ + `

+`, + ` + {token =>

} +
+`, +]; import I18nMulti from './i18n_multi'; const I18nMultiSource = require('!!raw-loader!./i18n_multi'); const I18nMultiHtml = renderToHtml(I18nMulti); +const multiValueSnippet = [ + `const [label, text] = useEuiI18n( + ['filename.label', 'filename.text'], + ['Default Label', 'Default Text'] +); + +return

{text}

; +`, + ` + {([label, text]) =>

{text}

} +
+`, +]; import I18nNumber from './i18n_number'; const I18nNumberSource = require('!!raw-loader!./i18n_number'); const I18nNumberHtml = renderToHtml(I18nNumber); +const numberSnippet = [ + `Formatted count of users: +`, +]; import Context from './context'; const contextSource = require('!!raw-loader!./context'); @@ -44,37 +77,41 @@ export const I18nExample = { ], text: (

- EuiI18n allows localizing string and numeric values - for internationalization. At its simplest, the component takes{' '} - token and default props.  - token provides a reference to use when looking for - a localized value to render and default provides - the untranslated value. + useEuiI18n and EuiI18n allows + localizing string and numeric values for internationalization. There + are two provided ways to use this: a React hook and a render prop + component. In their simplest form, these take a{' '} + token and a default value.{' '} + token provides a reference to use when mapping to a + localized value and default provides the + untranslated value when no mapping is available.

), + snippet: basicSnippet, demo: , - props: { EuiI18n }, }, { - title: 'As a render prop', + title: 'Using localized values in attributes', source: [ { type: GuideSectionTypes.JS, - code: i18nRenderPropSource, + code: i18nAttributeSource, }, { type: GuideSectionTypes.HTML, - code: i18nRenderPropHtml, + code: i18nAttributeHtml, }, ], text: (

Some times a localized value is needed for a prop instead of rendering - directly to the DOM. In these cases EuiI18n can be - passed a render prop child which is called with the localized value. + directly to the DOM. In these cases useEuiI18n can be + called inline, or EuiI18n can be used as a render + prop child which is called with the localized value.

), - demo: , + snippet: attributeSnippet, + demo: , }, { title: 'Multi-value lookup', @@ -91,12 +128,14 @@ export const I18nExample = { text: (

If many localized values are needed in a small area, multiple tokens - can be retrieved in a single render prop. In this case the{' '} - token/default props are replaced - by the pluralized tokens/ - defaults. + can be retrieved from the hook or via a single render prop. In this + case the token/default props are + replaced by the pluralized tokens/ + defaults. Value injection is not supported when + processing more than one token.

), + snippet: multiValueSnippet, demo: , }, { @@ -120,6 +159,7 @@ export const I18nExample = { render prop.

), + snippet: numberSnippet, demo: , }, { diff --git a/src-docs/src/views/i18n/i18n_multi.js b/src-docs/src/views/i18n/i18n_multi.js index 0e296394c01..77402b92fac 100644 --- a/src-docs/src/views/i18n/i18n_multi.js +++ b/src-docs/src/views/i18n/i18n_multi.js @@ -5,29 +5,61 @@ import { EuiSpacer, EuiText, EuiI18n, + EuiTitle, + useEuiI18n, } from '../../../../src/components'; export default () => { + const [title, description] = useEuiI18n( + ['euiI18nMulti.title', 'euiI18nMulti.description'], + ['Card Title', 'Card Description'], + {} + ); return ( -
- -

- Both title and description for the card are looked up in one call to{' '} - EuiI18n -

-
- - - {([title, description]) => ( - - )} - -
+ <> + +

useEuiI18n with multiple tokens

+
+
+ +

+ Both title and description for the card are looked up in one call to{' '} + useEuiI18n +

+
+ + +
+ + + + +

EuiI18n render prop with multiple tokens

+
+
+ +

+ Both title and description for the card are looked up in one call to{' '} + EuiI18n +

+
+ + + {([title, description]) => ( + + )} + +
+ ); }; diff --git a/src-docs/src/views/i18n/i18n_renderprop.js b/src-docs/src/views/i18n/i18n_renderprop.js deleted file mode 100644 index 7f9dcdb2cfd..00000000000 --- a/src-docs/src/views/i18n/i18n_renderprop.js +++ /dev/null @@ -1,30 +0,0 @@ -import React, { Fragment } from 'react'; - -import { - EuiCode, - EuiFieldText, - EuiI18n, - EuiFormRow, -} from '../../../../src/components'; - -export default () => { - return ( - -
- - {placeholderName => ( - - This text field's placeholder reads from{' '} - i18n.renderpropexample - - }> - - - )} - -
-
- ); -}; diff --git a/src/components/index.js b/src/components/index.js index ef981a0b58c..affe7fa1790 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -194,7 +194,7 @@ export { EuiImage } from './image'; export { useInnerText, EuiInnerText, useRenderToText } from './inner_text'; -export { EuiI18n, EuiI18nNumber } from './i18n'; +export { EuiI18n, EuiI18nNumber, useEuiI18n } from './i18n'; export { EuiLoadingKibana, From 59f431565c345fae66fc1c341492914a9b2195c0 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 16 Jul 2020 11:43:29 -0600 Subject: [PATCH 3/7] Add support to fetch-1i8n-strings or useEuiI18n to match EuiI18n extraction --- scripts/babel/fetch-i18n-strings.js | 42 ++++++++- .../i18n/__snapshots__/i18n.test.tsx.snap | 14 +++ src/components/i18n/i18n.test.tsx | 16 ++++ src/components/i18n/i18n.tsx | 4 +- src/components/image/image.tsx | 89 +++++++++---------- 5 files changed, 115 insertions(+), 50 deletions(-) diff --git a/scripts/babel/fetch-i18n-strings.js b/scripts/babel/fetch-i18n-strings.js index cfca578162a..37c0d92e618 100644 --- a/scripts/babel/fetch-i18n-strings.js +++ b/scripts/babel/fetch-i18n-strings.js @@ -18,6 +18,36 @@ function getCodeForExpression(expressionNode) { ])).code; } +function handleHookPath(path) { + const symbols = []; + + const arguments = path.node.arguments; + + if (arguments[0].type !== 'StringLiteral') return symbols; + + const token = arguments[0].value; + const defStringNode = arguments[1]; + let defString; + let highlighting; + + if (defStringNode.type === 'StringLiteral') { + defString = defStringNode.value; + highlighting = 'string'; + } else if (defStringNode.type === 'ArrowFunctionExpression') { + defString = getCodeForExpression(defStringNode); + highlighting = 'code'; + } + + symbols.push({ + token, + defString, + highlighting, + loc: path.node.loc, + }); + + return symbols; +} + function handleJSXPath(path) { const symbols = []; @@ -76,7 +106,17 @@ function traverseFile(filepath) { ); } } - } + }, + CallExpression(path) { + if (path.node.callee && path.node.callee.type === 'Identifier' && path.node.callee.name === 'useEuiI18n') { + const symbols = handleHookPath(path); + for (let i = 0; i < symbols.length; i++) { + tokenMappings.push( + { ...symbols[i], filepath: relative(rootDir, filepath) } + ); + } + } + }, } ); } diff --git a/src/components/i18n/__snapshots__/i18n.test.tsx.snap b/src/components/i18n/__snapshots__/i18n.test.tsx.snap index f099e8aa0d2..f953b842c6e 100644 --- a/src/components/i18n/__snapshots__/i18n.test.tsx.snap +++ b/src/components/i18n/__snapshots__/i18n.test.tsx.snap @@ -475,6 +475,20 @@ exports[`EuiI18n reading values from context rendering to dom renders a mapped s `; +exports[`EuiI18n useEuiI18n unmapped calls a function and renders the result to the dom 1`] = ` + +
+

+ This is a + callback + with + values + . +

+
+
+`; + exports[`EuiI18n useEuiI18n unmapped handles multiple tokens 1`] = `

diff --git a/src/components/i18n/i18n.test.tsx b/src/components/i18n/i18n.test.tsx index 626280e32e5..a2e32f800cb 100644 --- a/src/components/i18n/i18n.test.tsx +++ b/src/components/i18n/i18n.test.tsx @@ -302,6 +302,22 @@ describe('EuiI18n', () => { 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 = () => ( +
{useEuiI18n('test', renderCallback, values)}
+ ); + const component = mount(); + expect(component).toMatchSnapshot(); + + expect(renderCallback).toHaveBeenCalledWith(values); + }); }); }); diff --git a/src/components/i18n/i18n.tsx b/src/components/i18n/i18n.tsx index f164dd8a8f0..b8127f23e24 100644 --- a/src/components/i18n/i18n.tsx +++ b/src/components/i18n/i18n.tsx @@ -150,12 +150,12 @@ function useEuiI18n< T extends {}, DEFAULT extends Renderable, DEFAULTS extends any[] ->(token: string, defaultValue: string, values?: T): string; +>(token: string, defaultValue: DEFAULT, values?: T): string; function useEuiI18n< T extends {}, DEFAULT extends Renderable, DEFAULTS extends any[] ->(tokens: string[], defaultValues: string[]): string[]; +>(tokens: string[], defaultValues: DEFAULTS): string[]; function useEuiI18n(...props: any[]) { const i18nConfig = useContext(I18nContext); const { mapping, mappingFunc } = i18nConfig; diff --git a/src/components/image/image.tsx b/src/components/image/image.tsx index 6178f175b80..fc8271f5bf3 100644 --- a/src/components/image/image.tsx +++ b/src/components/image/image.tsx @@ -30,7 +30,7 @@ import { EuiOverlayMask } from '../overlay_mask'; import { EuiIcon } from '../icon'; -import { EuiI18n } from '../i18n'; +import { useEuiI18n } from '../i18n'; import { EuiFocusTrap } from '../focus_trap'; @@ -164,62 +164,57 @@ export const EuiImage: FunctionComponent = ({
- - {(closeImage: string) => ( - + {optionalCaption}
); + const fullscreenLabel = useEuiI18n( + 'euiImage.openImage', + 'Open full screen {alt} image', + { alt } + ); if (allowFullScreen) { return (
- - {(openImage: string) => ( - - )} - + {optionalCaption}
); From d8230bcf1788efe9ba7de2b022458ab1683f0ba8 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 16 Jul 2020 12:24:34 -0600 Subject: [PATCH 4/7] Fix up return types for useEuiI18n --- src/components/i18n/i18n.tsx | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/components/i18n/i18n.tsx b/src/components/i18n/i18n.tsx index b8127f23e24..6f705cc86d7 100644 --- a/src/components/i18n/i18n.tsx +++ b/src/components/i18n/i18n.tsx @@ -22,6 +22,7 @@ import React, { ReactChild, FunctionComponent, useContext, + ReactElement, } from 'react'; import { EuiI18nConsumer } from '../context'; import { ExclusiveUnion } from '../common'; @@ -146,16 +147,25 @@ const EuiI18n = < ); -function useEuiI18n< - T extends {}, - DEFAULT extends Renderable, - DEFAULTS extends any[] ->(token: string, defaultValue: DEFAULT, values?: T): string; -function useEuiI18n< - T extends {}, - DEFAULT extends Renderable, - DEFAULTS extends any[] ->(tokens: string[], defaultValues: DEFAULTS): string[]; +// A single default could be a string, react child, or render function +type DefaultRenderType> = K extends ReactChild + ? K + : (K extends () => infer RetValue ? RetValue : never); + +// An array with multiple defaults can only be an array of strings or elements +type DefaultsRenderType< + K extends Array +> = K extends Array ? Item : never; + +function useEuiI18n>( + token: string, + defaultValue: DEFAULT, + values?: T +): DefaultRenderType; +function useEuiI18n>( + tokens: string[], + defaultValues: DEFAULTS +): Array>; function useEuiI18n(...props: any[]) { const i18nConfig = useContext(I18nContext); const { mapping, mappingFunc } = i18nConfig; From de377fca6aed65f330957e335f05232ad5fa180b Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 16 Jul 2020 13:26:09 -0600 Subject: [PATCH 5/7] Updated custom eslint i18n rule/package to lint useEuiI18n usages --- scripts/eslint-plugin/i18n.js | 198 ++++++++++++++++++++++++++++- scripts/eslint-plugin/i18n.test.js | 110 +++++++++++++++- 2 files changed, 302 insertions(+), 6 deletions(-) diff --git a/scripts/eslint-plugin/i18n.js b/scripts/eslint-plugin/i18n.js index 15ed6887a1a..7d16ffc0867 100644 --- a/scripts/eslint-plugin/i18n.js +++ b/scripts/eslint-plugin/i18n.js @@ -15,8 +15,8 @@ function attributesArrayToLookup(attributesArray) { } function getDefinedValues(valuesNode) { - if (valuesNode == null || valuesNode.expression.properties == null) return new Set(); - return valuesNode.expression.properties.reduce( + if (valuesNode == null || valuesNode.properties == null) return new Set(); + return valuesNode.properties.reduce( (valueNames, property) => { valueNames.add(property.key.name); return valueNames; @@ -223,7 +223,9 @@ module.exports = { } // validate default string interpolation matches values - const valueNames = getDefinedValues(attributes.values); + const valueNames = getDefinedValues( + attributes.values && attributes.values.expression + ); if (attributes.default.type === 'Literal') { // default is a string literal @@ -319,7 +321,195 @@ module.exports = { } // debugger; - } + }, + CallExpression(node) { + // Only process calls to useEuiI18n + if ( + !node.callee || + node.callee.type !== 'Identifier' || + node.callee.name !== 'useEuiI18n' + ) + return; + + const arguments = node.arguments; + + const isSingleToken = arguments[0].type === 'Literal'; + + // validate argument types + if (isSingleToken) { + // default must be either a Literal of an ArrowFunctionExpression + const defaultArg = arguments[1]; + const isLiteral = defaultArg.type === 'Literal'; + const isArrowExpression = + defaultArg.type === 'ArrowFunctionExpression'; + if (!isLiteral && !isArrowExpression) { + context.report({ + node, + loc: defaultArg.loc, + messageId: 'invalidDefaultType', + data: { type: defaultArg.type }, + }); + return; + } + } else { + const tokensArg = arguments[0]; + const defaultsArg = arguments[1]; + + // tokens must be an array of Literals + if (tokensArg.type !== 'ArrayExpression') { + context.report({ + node, + loc: tokensArg.loc, + messageId: 'invalidTokensType', + data: { type: tokensArg.type }, + }); + return; + } + + for (let i = 0; i < tokensArg.elements.length; i++) { + const tokenNode = tokensArg.elements[i]; + if ( + tokenNode.type !== 'Literal' || + typeof tokenNode.value !== 'string' + ) { + context.report({ + node, + loc: tokenNode.loc, + messageId: 'invalidTokensType', + data: { type: tokenNode.type } + }); + return; + } + } + + // defaults must be an array of either Literals or ArrowFunctionExpressions + if (defaultsArg.type !== 'ArrayExpression') { + context.report({ + node, + loc: defaultsArg.loc, + messageId: 'invalidDefaultsType', + data: { type: defaultsArg.type } + }); + return; + } + + for (let i = 0; i < defaultsArg.elements.length; i++) { + const defaultNode = defaultsArg.elements[i]; + if ( + defaultNode.type !== 'Literal' || + typeof defaultNode.value !== 'string' + ) { + context.report({ + node, + loc: defaultNode.loc, + messageId: 'invalidDefaultsType', + data: { type: defaultNode.type } + }); + return; + } + } + } + + if (isSingleToken) { + const tokenArgument = arguments[0]; + const defaultArgument = arguments[1]; + const valuesArgument = arguments[2]; + + // validate token format + const tokenParts = tokenArgument.value.split('.'); + if ( + tokenParts.length <= 1 || + tokenParts[0] !== expectedTokenNamespace + ) { + context.report({ + node, + loc: tokenArgument.loc, + messageId: 'invalidToken', + data: { + tokenValue: tokenArgument.value, + tokenNamespace: expectedTokenNamespace, + }, + }); + } + + // validate default string interpolation matches values + const valueNames = getDefinedValues(valuesArgument); + + if (defaultArgument.type === 'Literal') { + // default is a string literal + const expectedNames = getExpectedValueNames(defaultArgument.value); + if (areSetsEqual(expectedNames, valueNames) === false) { + context.report({ + node, + loc: valuesArgument.loc, + messageId: 'mismatchedValues', + data: { + expected: formatSet(expectedNames), + provided: formatSet(valueNames), + }, + }); + } + } else { + // default is a function + // validate the destructured param defined by default function match the values + const defaultFn = defaultArgument; + const objProperties = + defaultFn.params && defaultFn.params[0] + ? defaultFn.params[0].properties + : []; + const expectedNames = new Set( + objProperties.map(property => property.key.name) + ); + if (areSetsEqual(valueNames, expectedNames) === false) { + context.report({ + node, + loc: valuesArgument.loc, + messageId: 'mismatchedValues', + data: { + expected: formatSet(expectedNames), + provided: formatSet(valueNames), + }, + }); + } + } + } else { + // has multiple tokens + const tokensArgument = arguments[0]; + const defaultsArgument = arguments[1]; + + // validate their names + const tokens = tokensArgument.elements; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const tokenParts = token.value.split('.'); + if ( + tokenParts.length <= 1 || + tokenParts[0] !== expectedTokenNamespace + ) { + context.report({ + node, + loc: token.loc, + messageId: 'invalidToken', + data: { tokenValue: token.value, tokenNamespace: expectedTokenNamespace } + }); + } + } + + // validate the number of tokens equals the number of defaults + const defaults = defaultsArgument.elements; + if (tokens.length !== defaults.length) { + context.report({ + node, + loc: node.loc, + messageId: 'mismatchedTokensAndDefaults', + data: { + tokenLength: tokens.length, + defaultsLength: defaults.length, + }, + }); + } + } + }, // callback functions }; } diff --git a/scripts/eslint-plugin/i18n.test.js b/scripts/eslint-plugin/i18n.test.js index 6b3045d71d9..ed3585b7c74 100644 --- a/scripts/eslint-plugin/i18n.test.js +++ b/scripts/eslint-plugin/i18n.test.js @@ -2,12 +2,13 @@ const rule = require('./i18n'); const RuleTester = require('eslint').RuleTester; const ruleTester = new RuleTester({ - parser: 'babel-eslint' + parser: require.resolve('babel-eslint') }); const valid = [ + /** EuiI18n **/ // nothing to validate against - '', + '', // values agree with default string ``, @@ -30,8 +31,22 @@ const valid = [ // default callback params match values ` name}/>`, + + /** useEuiI18n **/ + // nothing to validate against + `useI18n('euiFooBar.tokenName', 'Some default value')`, + + // values agree with default string + `useEuiI18n('euiFooBar.tokenName', '{value}, {value2}', { value: 'Hello', value2: 'World' })`, + + // valid tokens + `useEuiI18n(['euiFooBar.token1', 'euiFooBar.token2'], ['value1', 'value 2'])`, + + // default callback params match values + `useEuiI18n('euiFooBar.token', ({ name }) => name, { name: 'John' })`, ]; const invalid = [ + /** EuiI18n **/ // token doesn't match file name { code: '', @@ -157,6 +172,97 @@ const invalid = [ code: ``, errors: [{ messageId: 'invalidDefaultsType', data: { type: 'Literal' } }] }, + + // /** useEuiI18n **/ + // token doesn't match file name + { + code: `useEuiI18n('euiFooeyBar.tokenName', 'Some default value')`, + errors: [{ messageId: 'invalidToken', data: { tokenValue: 'euiFooeyBar.tokenName', tokenNamespace: 'euiFooBar' } }] + }, + + // token doesn't have at least two parts + { + code: `useEuiI18n('euiFooBar', 'Some default value')`, + errors: [{ messageId: 'invalidToken', data: { tokenValue: 'euiFooBar', tokenNamespace: 'euiFooBar' } }] + }, + { + code: `useEuiI18n('tokenName', 'Some default value')`, + errors: [{ messageId: 'invalidToken', data: { tokenValue: 'tokenName', tokenNamespace: 'euiFooBar' } }] + }, + + // invalid tokens + { + code: `useEuiI18n(['euiFooBar.token1', 'token2'], ['value1', 'value 2'])`, + errors: [{ messageId: 'invalidToken', data: { tokenValue: 'token2', tokenNamespace: 'euiFooBar' } }] + }, + { + code: `useEuiI18n(['euiFooeyBar.token1', 'euiFooBar.token2'], ['value1', 'value 2'])`, + errors: [{ messageId: 'invalidToken', data: { tokenValue: 'euiFooeyBar.token1', tokenNamespace: 'euiFooBar' } }] + }, + { + code: `useEuiI18n(['euiFooBar.token1'], ['value1', 'value 2'])`, + errors: [{ messageId: 'mismatchedTokensAndDefaults', data: { tokenLength: 1, defaultsLength: 2 } }] + }, + + // values not in agreement with default string + { + code: `useEuiI18n('euiFooBar.tokenName', '{value}, {value2}', { valuee: 'Hello', value2: 'World' })`, + errors: [{ + messageId: 'mismatchedValues', + data: { + expected: 'value, value2', + provided: 'value2, valuee' + } + }] + }, + { + code: `useEuiI18n('euiFooBar.tokenName', '{valuee}, {value2}', { value: 'Hello', value2: 'World' })`, + errors: [{ + messageId: 'mismatchedValues', + data: { + expected: 'value2, valuee', + provided: 'value, value2' + } + }] + }, + + // default callback params don't match values + { + code: `useEuiI18n('euiFooBar.token', ({ name }) => name, { nare: 'John' })`, + errors: [{ + messageId: 'mismatchedValues', + data: { + expected: 'name', + provided: 'nare' + } + }] + }, + + // invalid attribute types + { + code: `useEuiI18n('euiFooBar.token', ['value'])`, + errors: [{ messageId: 'invalidDefaultType', data: { type: 'ArrayExpression' } }] + }, + { + code: `useEuiI18n(5, ['value'])`, + errors: [{ messageId: 'invalidDefaultType', data: { type: 'ArrayExpression' } }] + }, + { + code: `useEuiI18n([5], ['value'])`, + errors: [{ messageId: 'invalidTokensType', data: { type: 'Literal' } }] + }, + { + code: `useEuiI18n(['euiFooBar.token'], 'value')`, + errors: [{ messageId: 'invalidDefaultsType', data: { type: 'Literal' } }] + }, + { + code: `useEuiI18n(['euiFooBar.token'], 5)`, + errors: [{ messageId: 'invalidDefaultsType', data: { type: 'Literal' } }] + }, + { + code: `useEuiI18n(['euiFooBar.token'], [5])`, + errors: [{ messageId: 'invalidDefaultsType', data: { type: 'Literal' } }] + }, ]; function withFilename(ruleset) { From 3850fe255f28b44fb822feb0952e71441af10031 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 16 Jul 2020 13:33:16 -0600 Subject: [PATCH 6/7] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c44526ee6bc..ef6014adcc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +- Added `useEuiI18n` hook for localization ([#3749](https://github.com/elastic/eui/pull/3749)) + **Bug fixes** - Fixed `EuiComboBox` always showing a scrollbar ([#3744](https://github.com/elastic/eui/pull/3744)) From 05de2fd502901661698093de96c5b2e4be997472 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 16 Jul 2020 15:17:42 -0600 Subject: [PATCH 7/7] Remove something I was testing with and lost where I had placeed it. --- src-docs/src/views/i18n/i18n_multi.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-docs/src/views/i18n/i18n_multi.js b/src-docs/src/views/i18n/i18n_multi.js index 77402b92fac..8044ef2c90b 100644 --- a/src-docs/src/views/i18n/i18n_multi.js +++ b/src-docs/src/views/i18n/i18n_multi.js @@ -12,8 +12,7 @@ import { export default () => { const [title, description] = useEuiI18n( ['euiI18nMulti.title', 'euiI18nMulti.description'], - ['Card Title', 'Card Description'], - {} + ['Card Title', 'Card Description'] ); return ( <>