Repository: kiwicom/mobile (js)

Issue:
iOS - When the screen is presented modally, swipe to back should not be possible
[video](https://trello-attachments.s3.amazonaws.com/5a742fedd88b9dc00a44bcc1/5b47424b9afae4ac2f985c39/78c18162cf725caccb5de36ca8d39262/ScreenRecording_07-12-2018_13-58-47.MP4)

Pull request:
fix: Do not allow swipe back on iOS modals
Fixes #871.

- [x] Fix withNativeNavigation for modals
- [x] Write unit test for the whole withNativeNavigation

Patch:
diff --git a/.eslintignore b/.eslintignore
index 62fec76a..d2be9470 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,3 +1,4 @@
 app/core/config/flow-typed
 **/node_modules/**
-**/RNAndroidPlayground/**
\ No newline at end of file
+**/RNAndroidPlayground/**
+/coverage
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 2fb795cf..cc7aed08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,6 @@ buck-out/
 *.cer
 *.mobileprovision
 *.dSYM.zip
+
+# Jest
+/coverage
\ No newline at end of file
diff --git a/app/core/config/NativeModulesMocks/RNKiwiGestureController.js b/app/core/config/NativeModulesMocks/RNKiwiGestureController.js
index b01b2547..126ad3ee 100644
--- a/app/core/config/NativeModulesMocks/RNKiwiGestureController.js
+++ b/app/core/config/NativeModulesMocks/RNKiwiGestureController.js
@@ -5,4 +5,6 @@ import { NativeModules } from 'react-native';
 NativeModules.RNKiwiGestureController = {
   enableGestures: jest.fn((moduleName: string) => moduleName),
   disableGestures: jest.fn((moduleName: string) => moduleName),
+  closeModal: jest.fn(),
+  invokeDefaultBackButton: jest.fn(),
 };
diff --git a/packages/shared/src/WithNativeNavigation.js b/packages/shared/src/WithNativeNavigation.js
index f641cabc..35918d20 100644
--- a/packages/shared/src/WithNativeNavigation.js
+++ b/packages/shared/src/WithNativeNavigation.js
@@ -1,10 +1,12 @@
-/* @flow */
+// @flow
 
 import * as React from 'react';
 import { GestureController } from '@kiwicom/mobile-shared';
 
 type NavigationState = {|
-  index: number,
+  +routes: $ReadOnlyArray<{
+    +routes: $ReadOnlyArray<mixed>,
+  }>,
 |};
 
 type InjectedProps = {|
@@ -17,27 +19,37 @@ function withNativeNavigation<Props: {}>(
   moduleName: string,
 ): React.ComponentType<$Diff<Props, InjectedProps>> {
   return class WithNativeNavigation extends React.Component<*> {
-    currentIndex: number;
+    isEnabled: boolean;
+    lastCall: '' | 'disabled' | 'enabled';
 
     constructor(props) {
       super(props);
-      this.currentIndex = 0;
+      this.isEnabled = true;
+      this.lastCall = '';
     }
     onNavigationStateChange = (
       previousState: NavigationState,
       currentState: NavigationState,
     ) => {
-      if (currentState.index === 0) {
+      // Native gesture should only be enabled in the very first screen
+      // Also, there should not be modal (like first screen + modal)
+      const isDisabled =
+        currentState.routes.length > 1 ||
+        currentState.routes[0].routes.length > 1;
+
+      if (!isDisabled && this.lastCall !== 'enabled') {
         GestureController.enableGestures(moduleName);
-        this.currentIndex = 0;
-      } else if (currentState.index > 0) {
+        this.lastCall = 'enabled';
+        this.isEnabled = true;
+      } else if (this.lastCall !== 'disabled') {
         GestureController.disableGestures(moduleName);
-        this.currentIndex = currentState.index;
+        this.lastCall = 'disabled';
+        this.isEnabled = false;
       }
     };
 
     onBackClicked = () => {
-      if (this.currentIndex === 0) {
+      if (this.isEnabled) {
         GestureController.invokeDefaultBackButton();
         return true;
       }


Test patch:
diff --git a/packages/shared/src/__tests__/WithNativeNavigation.test.js b/packages/shared/src/__tests__/WithNativeNavigation.test.js
new file mode 100644
index 00000000..f2bf2307
--- /dev/null
+++ b/packages/shared/src/__tests__/WithNativeNavigation.test.js
@@ -0,0 +1,124 @@
+// @flow
+
+import * as React from 'react';
+import renderer from 'react-test-renderer';
+import { GestureController } from '@kiwicom/mobile-shared';
+
+import WithNativeNavigation from '../WithNativeNavigation';
+
+type Props = {
+  onNavigationStateChange: () => void,
+  onBackClicked: () => void,
+};
+
+describe('WithNativeNavigation', () => {
+  const WrappedComponent = class TestComponent extends React.Component<Props> {
+    render() {
+      return null;
+    }
+  };
+
+  const TestKey = 'test-key';
+  const getComponent = () => {
+    const Component = WithNativeNavigation(WrappedComponent, TestKey);
+    return renderer.create(<Component />);
+  };
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('enables gestures if it was not enabled', () => {
+    const onNavigationStateChange = getComponent().getInstance()
+      .onNavigationStateChange;
+
+    const callEnable = () =>
+      onNavigationStateChange(
+        {},
+        {
+          routes: [
+            {
+              routes: [],
+            },
+          ],
+        },
+      );
+
+    callEnable();
+    expect(GestureController.enableGestures).toHaveBeenCalledTimes(1);
+
+    callEnable();
+    // Still 1 time because we do not call if it was the last call
+    expect(GestureController.enableGestures).toHaveBeenCalledTimes(1);
+  });
+
+  it('disables gestures if it was not enabled 1', () => {
+    const onNavigationStateChange = getComponent().getInstance()
+      .onNavigationStateChange;
+
+    const callDisable = () =>
+      onNavigationStateChange(
+        {},
+        {
+          routes: [
+            {
+              routes: [{ index: 0 }, { index: 1 }],
+            },
+          ],
+        },
+      );
+
+    callDisable();
+    expect(GestureController.disableGestures).toHaveBeenCalledTimes(1);
+
+    callDisable();
+    // Still 1 time because we do not call if it was the last call
+    expect(GestureController.disableGestures).toHaveBeenCalledTimes(1);
+  });
+
+  it('disables gestures if it was not enabled 2', () => {
+    const onNavigationStateChange = getComponent().getInstance()
+      .onNavigationStateChange;
+
+    onNavigationStateChange(
+      {},
+      {
+        routes: [
+          {
+            routes: [],
+          },
+          {
+            routes: [],
+          },
+        ],
+      },
+    );
+    expect(GestureController.disableGestures).toHaveBeenCalledTimes(1);
+  });
+
+  it('handles the back button', () => {
+    const instance = getComponent().getInstance();
+    const onNavigationStateChange = instance.onNavigationStateChange;
+    const onBackClicked = instance.onBackClicked;
+
+    // Enabled first state
+    expect(onBackClicked()).toBe(true);
+    expect(GestureController.invokeDefaultBackButton).toHaveBeenCalledTimes(1);
+
+    GestureController.invokeDefaultBackButton.mockClear();
+
+    // Disabled it
+    onNavigationStateChange(
+      {},
+      {
+        routes: [
+          {
+            routes: [{ index: 0 }, { index: 1 }],
+          },
+        ],
+      },
+    );
+    expect(onBackClicked()).toBe(false);
+    expect(GestureController.invokeDefaultBackButton).toHaveBeenCalledTimes(0);
+  });
+});