Repository: kiwicom/mobile
Pull request #920 (2018-07-25 15:14:50)

MMB: Travel information form validations

tbergq: @RobinCsl 🚀 

Patch:
diff --git a/app/MMB/src/navigation/TravelDocumentModalScreen.js b/app/MMB/src/navigation/TravelDocumentModalScreen.js
index 7119f890..b5551b7e 100644
--- a/app/MMB/src/navigation/TravelDocumentModalScreen.js
+++ b/app/MMB/src/navigation/TravelDocumentModalScreen.js
@@ -16,6 +16,7 @@ import {
   Text,
   DatePicker,
   Switch,
+  ErrorMessage,
 } from '@kiwicom/mobile-shared';
 
 import TitleTranslation from '../components/TitleTranslation';
@@ -32,8 +33,14 @@ type State = {|
   idNumber: string,
   expiryDate: Date | null,
   noExpiry: boolean,
+  error: {|
+    idNumber: boolean,
+    expiryDate: boolean,
+  |},
 |};
 
+const noop = () => {};
+
 export default class TravelDocumentModalScreen extends React.Component<
   Props,
   State,
@@ -45,12 +52,18 @@ export default class TravelDocumentModalScreen extends React.Component<
       idNumber: props.idNumber || '',
       expiryDate: props.expiryDate,
       noExpiry: false,
+      error: {
+        idNumber: false,
+        expiryDate: true,
+      },
     };
   }
+
   static navigationOptions = ({ navigation }: Props) => {
     function goBack() {
       navigation.goBack(null);
     }
+    const onSave = navigation.state.params.onSave || noop;
     return {
       headerLeft: (
         <HeaderButton onPress={goBack}>
@@ -63,7 +76,10 @@ export default class TravelDocumentModalScreen extends React.Component<
         </HeaderTitle>
       ),
       headerRight: (
-        <HeaderButton onPress={goBack}>
+        <HeaderButton
+          onPress={onSave}
+          disabled={navigation.state.params.disabled}
+        >
           <Translation id="mmb.missing_informaiton.travel_document_modal_screen.save" />
         </HeaderButton>
       ),
@@ -71,16 +87,61 @@ export default class TravelDocumentModalScreen extends React.Component<
     };
   };
 
+  componentDidMount = () => {
+    this.props.navigation.setParams({ onSave: this.onSave, disabled: true });
+  };
+
   onIdNumberChange = (idNumber: string) => {
-    this.setState({ idNumber });
+    this.setState(
+      state => ({
+        idNumber,
+        error: {
+          ...state.error,
+          idNumber: idNumber.length > 0 && idNumber.length < 5,
+        },
+      }),
+      this.validate,
+    );
   };
 
   onDateChange = (expiryDate: Date) => {
-    this.setState({ expiryDate });
+    this.setState(
+      state => ({
+        expiryDate,
+        error: {
+          ...state.error,
+          expiryDate: false,
+        },
+      }),
+      this.validate,
+    );
   };
 
   onNoExpiryChange = (noExpiry: boolean) => {
-    this.setState({ noExpiry });
+    this.setState(
+      state => ({
+        noExpiry,
+        expiryDate: null,
+        error: {
+          ...state.error,
+          expiryDate: !noExpiry,
+        },
+      }),
+      this.validate,
+    );
+  };
+
+  onSave = () => {
+    // TODO: Send mutation
+    this.props.navigation.goBack();
+  };
+
+  validate = () => {
+    // Not using this.state.error.idNumber because if this.state.idNumber.length === 0
+    // we want to disable save button, but not show error message under input
+    const hasError =
+      this.state.idNumber.length < 5 || this.state.error.expiryDate;
+    this.props.navigation.setParams({ disabled: hasError });
   };
 
   render = () => (
@@ -99,9 +160,19 @@ export default class TravelDocumentModalScreen extends React.Component<
           defaultValue={this.state.idNumber}
           onChangeText={this.onIdNumberChange}
         />
+        {this.state.error.idNumber && (
+          <ErrorMessage style={styles.error}>
+            <Translation id="mmb.missing_informaiton.travel_document_modal_screen.id_number_error" />
+          </ErrorMessage>
+        )}
       </View>
       <View style={styles.inputContainer}>
-        <Text style={styles.label}>
+        <Text
+          style={[
+            styles.label,
+            this.state.noExpiry ? styles.noExpiry : styles.expiry,
+          ]}
+        >
           <Translation id="mmb.missing_informaiton.travel_document_modal_screen.passport_or_id_expiry" />
         </Text>
         <View style={styles.row}>
@@ -111,6 +182,7 @@ export default class TravelDocumentModalScreen extends React.Component<
               onDateChange={this.onDateChange}
               minDate={new Date()} // TODO: Should probably be the date of the last leg
               formatFunction="formatToBirthday"
+              disabled={this.state.noExpiry}
             />
           </View>
           <View style={[styles.row, styles.switchContainer]}>
@@ -131,6 +203,7 @@ export default class TravelDocumentModalScreen extends React.Component<
 const styles = StyleSheet.create({
   container: {
     padding: 15,
+    backgroundColor: Color.white,
   },
   icon: {
     color: Color.black,
@@ -158,4 +231,13 @@ const styles = StyleSheet.create({
   datePickerContainer: {
     flex: 1,
   },
+  error: {
+    alignSelf: 'flex-end',
+  },
+  noExpiry: {
+    color: Color.labelDisabled,
+  },
+  expiry: {
+    color: Color.textLight,
+  },
 });
diff --git a/app/hotels/src/allHotels/searchForm/guests/ErrorMessage.js b/app/hotels/src/allHotels/searchForm/guests/ErrorMessage.js
deleted file mode 100644
index c436993c..00000000
--- a/app/hotels/src/allHotels/searchForm/guests/ErrorMessage.js
+++ /dev/null
@@ -1,19 +0,0 @@
-// @flow
-
-import * as React from 'react';
-import { Text, Color, StyleSheet } from '@kiwicom/mobile-shared';
-import { type TranslationType } from '@kiwicom/mobile-localization';
-
-type Props = {|
-  children: TranslationType,
-|};
-
-export default function Message(props: Props): React.Node {
-  return <Text style={styles.error}>{props.children}</Text>;
-}
-
-const styles = StyleSheet.create({
-  error: {
-    color: Color.red.dark,
-  },
-});
diff --git a/app/hotels/src/allHotels/searchForm/guests/GuestsModal.js b/app/hotels/src/allHotels/searchForm/guests/GuestsModal.js
index d3dfc46a..31a83c50 100644
--- a/app/hotels/src/allHotels/searchForm/guests/GuestsModal.js
+++ b/app/hotels/src/allHotels/searchForm/guests/GuestsModal.js
@@ -2,7 +2,7 @@
 
 import * as React from 'react';
 import { View, ScrollView, InteractionManager } from 'react-native';
-import { Text, StyleSheet, Color } from '@kiwicom/mobile-shared';
+import { Text, StyleSheet, Color, ErrorMessage } from '@kiwicom/mobile-shared';
 import { Translation } from '@kiwicom/mobile-localization';
 
 import ChildrenAgesControl from './ChildrenAgesControl';
@@ -10,7 +10,6 @@ import type {
   RoomConfigurationType as UnsavedRoomConfigurationType,
   ChildAge,
 } from './GuestsTypes';
-import ErrorMessage from './ErrorMessage';
 import GuestsNumberControls from './GuestsNumberControls';
 
 const styles = StyleSheet.create({
diff --git a/packages/localization/src/vocabularies/ManageMyBooking.js b/packages/localization/src/vocabularies/ManageMyBooking.js
index 207c135e..e652bc23 100644
--- a/packages/localization/src/vocabularies/ManageMyBooking.js
+++ b/packages/localization/src/vocabularies/ManageMyBooking.js
@@ -268,6 +268,8 @@ export default {
   'mmb.missing_informaiton.travel_document_modal_screen.passport_or_id_expiry':
     'Passport or ID expiry date',
   'mmb.missing_informaiton.travel_document_modal_screen.no_expiry': 'No expiry',
+  'mmb.missing_informaiton.travel_document_modal_screen.id_number_error':
+    'Please recheck this, it seems too short',
 
   // MMB Booking Timeline
   'mmb.booking_timeline.today': 'Today',
diff --git a/packages/navigation/index.js b/packages/navigation/index.js
index bf0165e8..9e45622b 100644
--- a/packages/navigation/index.js
+++ b/packages/navigation/index.js
@@ -57,21 +57,35 @@ export const HeaderTitle = ({ children }: {| children: TranslationType |}) => {
 export const HeaderButton = ({
   children,
   onPress,
+  disabled,
 }: {|
-  children: React.Element<any>,
-  onPress: () => void,
+  +children: React.Element<any>,
+  +onPress: () => void,
+  +disabled: boolean,
 |}) => {
   return (
     <Touchable
       borderlessRipple={true}
       onPress={onPress}
       style={styleSheet.headerButton}
+      disabled={disabled}
     >
-      <Text style={styleSheet.headerButtonText}>{children}</Text>
+      <Text
+        style={[
+          styleSheet.headerButtonText,
+          disabled ? styleSheet.disabledButton : null,
+        ]}
+      >
+        {children}
+      </Text>
     </Touchable>
   );
 };
 
+HeaderButton.defaultProps = {
+  disabled: false,
+};
+
 const styleSheet = StyleSheet.create({
   title: {
     color: Color.textDark,
@@ -90,6 +104,9 @@ const styleSheet = StyleSheet.create({
     color: Color.brand,
     fontSize: 17,
   },
+  disabledButton: {
+    color: Color.inputBackground,
+  },
 });
 
 export const StackNavigator = (
diff --git a/packages/shared/index.js b/packages/shared/index.js
index 7307299c..808f58d7 100644
--- a/packages/shared/index.js
+++ b/packages/shared/index.js
@@ -21,6 +21,7 @@ export {
 export { default as AgePicker } from './src/forms/AgePicker';
 export { default as Checkbox } from './src/forms/Checkbox';
 export { default as DatePicker } from './src/forms/datePicker/DatePicker';
+export { default as ErrorMessage } from './src/forms/ErrorMessage';
 export { default as NumberControl } from './src/forms/NumberControl';
 export { default as Slider } from './src/forms/Slider';
 export { default as TextInput } from './src/forms/TextInput';
diff --git a/packages/shared/src/Color.js b/packages/shared/src/Color.js
index 4966262b..d301907e 100644
--- a/packages/shared/src/Color.js
+++ b/packages/shared/src/Color.js
@@ -76,6 +76,8 @@ export default {
   inputBackground: '#e8edf1',
   backgroundGray: '#f4f6f8',
   disabled: '#b8c1cc',
+  disabledInput: '#FAFBFC',
+  labelDisabled: '#DFE4EA',
 
   border: '#e8edf1',
 
diff --git a/packages/shared/src/forms/ErrorMessage.js b/packages/shared/src/forms/ErrorMessage.js
new file mode 100644
index 00000000..090689a9
--- /dev/null
+++ b/packages/shared/src/forms/ErrorMessage.js
@@ -0,0 +1,24 @@
+// @flow strict
+
+import * as React from 'react';
+import { type TranslationType } from '@kiwicom/mobile-localization';
+
+import Text from '../Text';
+import Color from '../Color';
+import StyleSheet from '../PlatformStyleSheet';
+import type { StylePropType } from '../../types/Styles';
+
+type Props = {|
+  +children: TranslationType,
+  +style?: StylePropType,
+|};
+
+export default function Message(props: Props): React.Node {
+  return <Text style={[styles.error, props.style]}>{props.children}</Text>;
+}
+
+const styles = StyleSheet.create({
+  error: {
+    color: Color.red.normal,
+  },
+});
diff --git a/packages/shared/src/forms/datePicker/DatePicker.android.js b/packages/shared/src/forms/datePicker/DatePicker.android.js
index 4195ad53..7764b0e8 100644
--- a/packages/shared/src/forms/datePicker/DatePicker.android.js
+++ b/packages/shared/src/forms/datePicker/DatePicker.android.js
@@ -7,6 +7,10 @@ import DatePickerButton from './DatePickerButton';
 import type { Props } from './DatePickerProps';
 
 export default class DatePicker extends React.Component<Props> {
+  static defaultProps = {
+    disabled: false,
+  };
+
   openDatePicker = () => {
     // The DatePickerButton onPress expects a method () => void,
     // We have to wrap the call to show datePicker with a method with ^^ signature
@@ -30,6 +34,7 @@ export default class DatePicker extends React.Component<Props> {
       date={this.props.date}
       iconComponent={this.props.iconComponent}
       formatFunction={this.props.formatFunction}
+      disabled={this.props.disabled}
     />
   );
 }
diff --git a/packages/shared/src/forms/datePicker/DatePicker.ios.js b/packages/shared/src/forms/datePicker/DatePicker.ios.js
index 727a8f28..c2e28826 100644
--- a/packages/shared/src/forms/datePicker/DatePicker.ios.js
+++ b/packages/shared/src/forms/datePicker/DatePicker.ios.js
@@ -25,6 +25,10 @@ export default class DatePicker extends React.Component<Props, State> {
 
   static getDerivedStateFromProps = ({ date }: Props) => ({ date });
 
+  static defaultProps = {
+    disabled: false,
+  };
+
   componentDidMount = () => {
     this.setState({ date: this.props.date || DateUtils.getUTCToday() });
   };
@@ -36,7 +40,9 @@ export default class DatePicker extends React.Component<Props, State> {
   };
 
   onSave = () => {
-    this.props.onDateChange(this.state.date);
+    // if this.state.date is null, fallback sent to DatepickerIOS is today's date
+    // if user clicks save and this.state.date is null, then he has selected today's date
+    this.props.onDateChange(this.state.date || DateUtils.getUTCToday());
   };
 
   onDateChange = (date: Date | string) => {
@@ -59,6 +65,7 @@ export default class DatePicker extends React.Component<Props, State> {
       maxDate,
       iconComponent,
       formatFunction,
+      disabled,
       ...rest
     } = this.props;
 
@@ -69,6 +76,7 @@ export default class DatePicker extends React.Component<Props, State> {
           date={date}
           iconComponent={iconComponent}
           formatFunction={formatFunction}
+          disabled={disabled}
         />
         <BarPopup
           isVisible={this.state.isPickerOpen}
@@ -82,7 +90,7 @@ export default class DatePicker extends React.Component<Props, State> {
             {...rest}
             minimumDate={minDate}
             maximumDate={maxDate}
-            date={this.state.date}
+            date={this.state.date || DateUtils.getUTCToday()}
             onDateChange={this.onDateChange}
             locale={DeviceInfo.getLocaleDashed()}
             mode="date"
diff --git a/packages/shared/src/forms/datePicker/DatePickerButton.js b/packages/shared/src/forms/datePicker/DatePickerButton.js
index 4b9202de..c7cb3085 100644
--- a/packages/shared/src/forms/datePicker/DatePickerButton.js
+++ b/packages/shared/src/forms/datePicker/DatePickerButton.js
@@ -15,6 +15,7 @@ import Color from '../../Color';
 
 type Props = {|
   +onPress: () => void,
+  +disabled: boolean,
   +date: ?Date,
   +formatFunction: ?DateFormatterFunctions,
   +iconComponent?: React.Node,
@@ -22,7 +23,11 @@ type Props = {|
 
 export default function DatePickerButton(props: Props) {
   return (
-    <Touchable style={styles.dateTouchBody} onPress={props.onPress}>
+    <Touchable
+      style={[styles.dateTouchBody, props.disabled ? styles.disabled : null]}
+      onPress={props.onPress}
+      disabled={props.disabled}
+    >
       <View style={styles.date}>
         {props.iconComponent}
         {props.date !== null && (
@@ -68,4 +73,7 @@ const styles = StyleSheet.create({
     },
     marginStart: 5,
   },
+  disabled: {
+    backgroundColor: Color.disabledInput,
+  },
 });
diff --git a/packages/shared/src/forms/datePicker/DatePickerProps.js b/packages/shared/src/forms/datePicker/DatePickerProps.js
index 5b966a74..2dedf876 100644
--- a/packages/shared/src/forms/datePicker/DatePickerProps.js
+++ b/packages/shared/src/forms/datePicker/DatePickerProps.js
@@ -11,4 +11,5 @@ export type Props = {|
   +maxDate?: Date,
   +iconComponent?: React.Node,
   +formatFunction?: DateFormatterFunctions,
+  +disabled: boolean,
 |};
diff --git a/schema.graphql b/schema.graphql
index a465578b..5b67d6f1 100644
--- a/schema.graphql
+++ b/schema.graphql
@@ -477,14 +477,14 @@ type BookingReturn implements BookingInterface & Node {
 }
 
 enum BookingStatus {
-  NEW
-  REFUNDED
-  PENDING
-  CONFIRMED
-  CANCELLED
-  DELETED
   CLOSED
-  EXPIRED
+  CONFIRMED
+  REFUNDED
+  NEW @deprecated(reason: "Never valid value for Booking Status, only for Booking Extra Status")
+  PENDING @deprecated(reason: "Never valid value for Booking Status, only for Booking Extra Status")
+  CANCELLED @deprecated(reason: "Never valid value for Booking Status, only for Booking Extra Status")
+  DELETED @deprecated(reason: "Never valid value for Booking Status, only for Booking Extra Status")
+  EXPIRED @deprecated(reason: "Never valid value for Booking Status, only for Booking Extra Status")
 }
 
 type BookingTimeline {
@@ -1839,7 +1839,7 @@ input PassengerInput {
   passengerId: Int!
 
   """Expiry date of travel document"""
-  documentExpiry: Date!
+  documentExpiry: Date
 
   """Number of travel document"""
   documentNumber: String!
@@ -2077,7 +2077,8 @@ type RootQuery {
     id: ID!
 
     """
-    Fetch only subsection of FAQ based on the current situation of customer.
+    DEPRECATED: Subsection of FAQ is handled automatically now, this has no
+    effect.Fetch only subsection of FAQ based on the current situation of customer.
     """
     section: FAQSection
   ): FAQCategory


Test patch:
diff --git a/app/MMB/src/navigation/__tests__/TravelDocumentModalScreen.test.js b/app/MMB/src/navigation/__tests__/TravelDocumentModalScreen.test.js
new file mode 100644
index 00000000..b5e2d201
--- /dev/null
+++ b/app/MMB/src/navigation/__tests__/TravelDocumentModalScreen.test.js
@@ -0,0 +1,84 @@
+// @flow strict
+
+import * as React from 'react';
+import renderer from 'react-test-renderer';
+
+import TravelDocumentModalScreen from '../TravelDocumentModalScreen';
+
+jest.mock('../../components/TitleTranslation.js', () => () => null);
+jest.mock('DatePickerIOS');
+
+const navigation = {
+  setParams: jest.fn(),
+};
+
+const getInstance = () =>
+  renderer
+    .create(
+      // $FlowExpectedError: All props not needed for this test
+      <TravelDocumentModalScreen expiryDate={null} navigation={navigation} />,
+    )
+    .getInstance();
+
+afterEach(() => {
+  jest.resetAllMocks();
+});
+
+describe('TravelDocumentModalScreen', () => {
+  it('should validate onIdNumberChange correctly', () => {
+    const instance = getInstance();
+    expect(instance.state.error.idNumber).toBe(false);
+
+    instance.onIdNumberChange('1');
+    expect(instance.state.error.idNumber).toBe(true);
+
+    instance.onIdNumberChange('12345');
+    expect(instance.state.error.idNumber).toBe(false);
+  });
+
+  it('should validate onDateChange correctly', () => {
+    const instance = getInstance();
+    expect(instance.state.error.expiryDate).toBe(true);
+
+    instance.onDateChange(new Date());
+    expect(instance.state.error.expiryDate).toBe(false);
+  });
+
+  it('should validate onNoExpiryChange correctly', () => {
+    const instance = getInstance();
+    expect(instance.state.error.expiryDate).toBe(true);
+
+    instance.onDateChange(new Date());
+    expect(instance.state.error.expiryDate).toBe(false);
+
+    instance.onNoExpiryChange(false);
+    expect(instance.state.error.expiryDate).toBe(true);
+  });
+
+  it('should disable header button correctly', () => {
+    const instance = getInstance();
+    jest.resetAllMocks();
+
+    instance.onIdNumberChange('12345');
+    expect(navigation.setParams).toHaveBeenCalledWith({ disabled: true });
+    expect(navigation.setParams).toHaveBeenCalledTimes(1);
+    jest.resetAllMocks();
+
+    instance.onDateChange(new Date());
+    expect(navigation.setParams).toHaveBeenCalledWith({ disabled: false });
+    expect(navigation.setParams).toHaveBeenCalledTimes(1);
+    jest.resetAllMocks();
+
+    instance.onIdNumberChange('2');
+    expect(navigation.setParams).toHaveBeenCalledWith({ disabled: true });
+    expect(navigation.setParams).toHaveBeenCalledTimes(1);
+    jest.resetAllMocks();
+
+    instance.onIdNumberChange('12345');
+    jest.resetAllMocks();
+    instance.onNoExpiryChange(true);
+    expect(navigation.setParams).toHaveBeenCalledWith({ disabled: false });
+    expect(navigation.setParams).toHaveBeenCalledTimes(1);
+    jest.resetAllMocks();
+  });
+});
diff --git a/packages/shared/src/forms/datePicker/__tests__/__snapshots__/DatePicker.test.js.snap b/packages/shared/src/forms/datePicker/__tests__/__snapshots__/DatePicker.test.js.snap
index 009ba571..978d6310 100644
--- a/packages/shared/src/forms/datePicker/__tests__/__snapshots__/DatePicker.test.js.snap
+++ b/packages/shared/src/forms/datePicker/__tests__/__snapshots__/DatePicker.test.js.snap
@@ -3,6 +3,7 @@
 exports[`DatePicker renders in playground 1`] = `
 <DatePicker
   date={2018-01-01T00:00:00.000Z}
+  disabled={false}
   iconComponent={
     <TextIcon
       code=""
@@ -21,6 +22,7 @@ exports[`DatePicker renders in playground 1`] = `
 exports[`DatePicker works with max date 1`] = `
 <DatePicker
   date={2018-01-01T00:00:00.000Z}
+  disabled={false}
   iconComponent={
     <TextIcon
       code=""
@@ -40,6 +42,7 @@ exports[`DatePicker works with max date 1`] = `
 exports[`DatePicker works with min date 1`] = `
 <DatePicker
   date={2018-01-01T00:00:00.000Z}
+  disabled={false}
   iconComponent={
     <TextIcon
       code=""
@@ -59,6 +62,7 @@ exports[`DatePicker works with min date 1`] = `
 exports[`DatePicker works with other date formats 1`] = `
 <DatePicker
   date={2018-01-01T00:00:00.000Z}
+  disabled={false}
   format="L"
   iconComponent={
     <TextIcon