Repository: kiwicom/mobile
Pull request #963 (2018-08-06 15:42:44)

Wrapper for React Native's Alert with translation

RobinCsl: @tbergq  🚀 

Patch:
diff --git a/packages/localization/index.js b/packages/localization/index.js
index dde2a78b..e8b771bf 100644
--- a/packages/localization/index.js
+++ b/packages/localization/index.js
@@ -3,6 +3,7 @@
 import * as React from 'react';
 
 import Translation from './src/Translation';
+import Alert from './src/Alert';
 import TranslationFragment from './src/TranslationFragment';
 import DateFormatter from './src/DateFormatter';
 import DateUtils from './src/DateUtils';
@@ -15,6 +16,7 @@ export {
   DateUtils,
   DeviceInfo,
   Translation,
+  Alert,
 };
 
 export type TranslationKeysType = TranslationKeys;
diff --git a/packages/localization/src/Alert.js b/packages/localization/src/Alert.js
new file mode 100644
index 00000000..43e94874
--- /dev/null
+++ b/packages/localization/src/Alert.js
@@ -0,0 +1,91 @@
+// @flow
+
+import { Alert as AlertNative } from 'react-native';
+
+import { type TranslationKeys } from './DefaultVocabulary';
+import { type TranslationPromise } from './CancellableTranslation';
+import { setTranslatedString, replaceValues } from './TranslationHelpers';
+
+type Translation =
+  | {|
+      +id: TranslationKeys,
+      +values?: Object,
+    |}
+  | {|
+      +passThrough: ?string | ?number,
+    |};
+
+type AlertButtons = Array<{
+  text?: Translation,
+  onPress?: ?() => void,
+  style?: AlertButtonStyle,
+}>;
+
+type NativeAlertButtons = Array<{
+  text?: string,
+  onPress?: ?() => void,
+  style?: AlertButtonStyle,
+}>;
+
+type AlertOptions = {
+  cancelable?: ?boolean,
+  onDismiss?: ?() => void,
+};
+
+type AlertButtonStyle = 'default' | 'cancel' | 'destructive';
+
+type AlertType = 'default' | 'plain-text' | 'secure-text' | 'login-password';
+
+const getTranslation = async (translation: ?Translation) => {
+  let translatePromise: TranslationPromise | null = null;
+  if (translation && translation.id) {
+    const translatedTitle = await setTranslatedString(
+      translation.id,
+      translatePromise,
+      translatedString => translatedString,
+    );
+    if (translatedTitle) {
+      return replaceValues(translatedTitle, translation.values);
+    }
+  } else if (translation && translation.passThrough) {
+    return String(translation.passThrough);
+  }
+  return undefined;
+};
+
+export default class Alert extends AlertNative {
+  static translatedAlert = async (
+    title: ?Translation,
+    message?: Translation,
+    buttons?: AlertButtons,
+    options?: AlertOptions,
+    type?: AlertType,
+  ) => {
+    const translatedTitleWithValues = await getTranslation(title);
+    const translatedMessageWithValues = await getTranslation(message);
+
+    let translatedButtons: NativeAlertButtons = [];
+    if (buttons && buttons.length >= 1) {
+      await Promise.all(
+        buttons.map(async button => {
+          if (button && button.text) {
+            const translatedButtonText = await getTranslation(button.text);
+            translatedButtons.push({
+              onPress: button.onPress,
+              style: button.style,
+              text: translatedButtonText,
+            });
+          }
+        }),
+      );
+    }
+
+    AlertNative.alert(
+      translatedTitleWithValues,
+      translatedMessageWithValues,
+      translatedButtons,
+      options,
+      type,
+    );
+  };
+}
diff --git a/packages/localization/src/Translation.js b/packages/localization/src/Translation.js
index 0ae96253..73e091ed 100644
--- a/packages/localization/src/Translation.js
+++ b/packages/localization/src/Translation.js
@@ -3,14 +3,12 @@
 import * as React from 'react';
 import { Text } from '@kiwicom/mobile-shared';
 
-import DefaultVocabulary, { type TranslationKeys } from './DefaultVocabulary';
+import { type TranslationKeys } from './DefaultVocabulary';
 import CaseTransform, {
   type SupportedTransformationsType,
 } from './transformations/CaseTransform';
-import {
-  cancellableTranslation,
-  type TranslationPromise,
-} from './CancellableTranslation';
+import { type TranslationPromise } from './CancellableTranslation';
+import { setTranslatedString, replaceValues } from './TranslationHelpers';
 
 type CommonProps = {|
   testID?: string,
@@ -78,44 +76,16 @@ export default class Translation extends React.Component<Props, State> {
     }
   };
 
-  setTranslatedString = async () => {
+  setTranslatedString = () => {
     if (this.props.id) {
-      const key = this.props.id;
-      const nativeKey = 'mobile.' + key;
-      try {
-        this.translatePromise = cancellableTranslation(nativeKey);
-        let translatedString = await this.translatePromise.promise;
-        if (translatedString === nativeKey) {
-          // fallback to our dummy vocabulary because native code didn't provide translation
-          translatedString = DefaultVocabulary[key];
-        }
-
-        if (translatedString === undefined) {
-          // Everything failed - native translation even our fallback. This usually
-          // means we are using key that does not exist. In this case the only thing
-          // we can actually do is to use original key as a text.
-          translatedString = key;
-        }
-        this.setState({ translatedString });
-        this.translatePromise = null;
-      } catch (err) {
-        this.translatePromise = null;
-      }
+      setTranslatedString(
+        this.props.id,
+        this.translatePromise,
+        translatedString => this.setState({ translatedString }),
+      );
     }
   };
 
-  replaceValues = (text: string, values?: Object): string => {
-    const VARIABLE_REGEX = /__([0-9]+_)?([a-zA-Z-_]+)__/g;
-    return text.replace(
-      VARIABLE_REGEX,
-      (match, variableIndex, variableName) => {
-        // 'variableIndex' is currently being ignored (we don't need it)
-        const value = values && values[variableName];
-        return value !== undefined ? value : '?';
-      },
-    );
-  };
-
   render = () => {
     const containerStyle = this.highlightTranslations
       ? {
@@ -142,7 +112,7 @@ export default class Translation extends React.Component<Props, State> {
       <Text style={containerStyle}>
         {/* $FlowExpectedError: we do not allow to use 'string' in the 'Text' components but translations are exceptions. */}
         {CaseTransform(
-          this.replaceValues(this.state.translatedString, values),
+          replaceValues(this.state.translatedString, values),
           this.props.textTransform,
         )}
       </Text>
diff --git a/packages/localization/src/TranslationHelpers.js b/packages/localization/src/TranslationHelpers.js
new file mode 100644
index 00000000..44a94503
--- /dev/null
+++ b/packages/localization/src/TranslationHelpers.js
@@ -0,0 +1,44 @@
+//@flow
+
+import DefaultVocabulary, { type TranslationKeys } from './DefaultVocabulary';
+import {
+  cancellableTranslation,
+  type TranslationPromise,
+} from './CancellableTranslation';
+
+export const setTranslatedString = async (
+  id: TranslationKeys,
+  translatePromiseRef: TranslationPromise | null = null,
+  callback: string => void | string,
+) => {
+  if (id) {
+    const nativeKey = 'mobile.' + id;
+    try {
+      translatePromiseRef = cancellableTranslation(nativeKey);
+      let translatedString = await translatePromiseRef.promise;
+      if (translatedString === nativeKey) {
+        // fallback to our dummy vocabulary because native code didn't provide translation
+        translatedString = DefaultVocabulary[id];
+      }
+      if (translatedString === undefined) {
+        // Everything failed - native translation even our fallback. This usually
+        // means we are using key that does not exist. In this case the only thing
+        // we can actually do is to use original key as a text.
+        translatedString = id;
+      }
+      translatePromiseRef = null;
+      return callback(translatedString);
+    } catch (err) {
+      translatePromiseRef = null;
+    }
+  }
+};
+
+export function replaceValues(text: string, values?: Object): string {
+  const VARIABLE_REGEX = /__([0-9]+_)?([a-zA-Z-_]+)__/g;
+  return text.replace(VARIABLE_REGEX, (match, variableIndex, variableName) => {
+    // 'variableIndex' is currently being ignored (we don't need it)
+    const value = values && values[variableName];
+    return value !== undefined ? value : '?';
+  });
+}


Test patch:
diff --git a/packages/localization/src/__tests__/Alert.test.js b/packages/localization/src/__tests__/Alert.test.js
new file mode 100644
index 00000000..c29ac277
--- /dev/null
+++ b/packages/localization/src/__tests__/Alert.test.js
@@ -0,0 +1,54 @@
+// @flow
+
+import NativeModules from 'react-native';
+
+import Alert from '../Alert';
+
+describe('Alert wrapper with translations', () => {
+  let spy;
+  beforeEach(() => {
+    spy = jest.spyOn(NativeModules.Alert, 'alert');
+  });
+  afterEach(() => {
+    spy.mockRestore();
+  });
+  it('should be possible to only have a title', async () => {
+    await Alert.translatedAlert({ passThrough: 'Hi' });
+    expect(spy).toHaveBeenCalledWith('Hi', undefined, [], undefined, undefined);
+  });
+  it('should be possible to only have a message', async () => {
+    await Alert.translatedAlert(undefined, { passThrough: 'bye' });
+    expect(spy).toHaveBeenCalledWith(
+      undefined,
+      'bye',
+      [],
+      undefined,
+      undefined,
+    );
+  });
+  it('should be possible to have a title and a message', async () => {
+    await Alert.translatedAlert({ passThrough: 'Hi' }, { passThrough: 'bye' });
+    expect(spy).toHaveBeenCalledWith('Hi', 'bye', [], undefined, undefined);
+  });
+  it('should be possible to have custom buttons', async () => {
+    const onPress = jest.fn();
+    await Alert.translatedAlert({ passThrough: 'Hi' }, { passThrough: 'bye' }, [
+      { text: { passThrough: 'Button Text1' }, onPress, style: 'default' },
+      { text: { passThrough: 'Button Text2' }, onPress, style: 'cancel' },
+      { text: { passThrough: 'Button Text3' }, onPress, style: 'destructive' },
+      { text: { passThrough: 'Button Text4' }, onPress, style: 'default' },
+    ]);
+    expect(spy).toHaveBeenCalledWith(
+      'Hi',
+      'bye',
+      [
+        { text: 'Button Text1', onPress, style: 'default' },
+        { text: 'Button Text2', onPress, style: 'cancel' },
+        { text: 'Button Text3', onPress, style: 'destructive' },
+        { text: 'Button Text4', onPress, style: 'default' },
+      ],
+      undefined,
+      undefined,
+    );
+  });
+});
diff --git a/packages/localization/src/__tests__/Translation.test.js b/packages/localization/src/__tests__/Translation.test.js
index ca48ab34..570e3c2c 100644
--- a/packages/localization/src/__tests__/Translation.test.js
+++ b/packages/localization/src/__tests__/Translation.test.js
@@ -5,6 +5,7 @@ import { NativeModules } from 'react-native';
 import renderer from 'react-test-renderer';
 
 import Translation from '../Translation';
+import { replaceValues } from '../TranslationHelpers';
 
 const CancellableTranslation = require('../CancellableTranslation');
 
@@ -30,7 +31,7 @@ it('should call NativeModules.RNTranslationManager.translateAsync with the key p
 
 it('replaces parameter', () => {
   expect(
-    Component.replaceValues('Your flight __number__ departs now.', {
+    replaceValues('Your flight __number__ departs now.', {
       number: 42,
     }),
   ).toBe('Your flight 42 departs now.');
@@ -38,7 +39,7 @@ it('replaces parameter', () => {
 
 it('replaces parameters', () => {
   expect(
-    Component.replaceValues(
+    replaceValues(
       'Your flight __1_number__ departs from __2_airport__ gate __3_gate__.',
       {
         number: 42,
@@ -50,18 +51,18 @@ it('replaces parameters', () => {
 });
 
 it('works without parameters', () => {
-  expect(Component.replaceValues('Your flight departs now.')).toBe(
+  expect(replaceValues('Your flight departs now.')).toBe(
     'Your flight departs now.',
   );
 });
 
 it('works without value to replace', () => {
-  expect(Component.replaceValues('Your flight __number__ departs now.')).toBe(
+  expect(replaceValues('Your flight __number__ departs now.')).toBe(
+    'Your flight ? departs now.',
+  );
+  expect(replaceValues('Your flight __number__ departs now.', {})).toBe(
     'Your flight ? departs now.',
   );
-  expect(
-    Component.replaceValues('Your flight __number__ departs now.', {}),
-  ).toBe('Your flight ? departs now.');
 });
 
 it('works with pass through translations', () => {