Repository: kiwicom/mobile
Pull request #1185 (2018-10-04 14:10:24)

Hotels: add order by modal


Patch:
diff --git a/app/hotels/src/HotelsFilterContext.js b/app/hotels/src/HotelsFilterContext.js
index 41988f48..056cd9aa 100644
--- a/app/hotels/src/HotelsFilterContext.js
+++ b/app/hotels/src/HotelsFilterContext.js
@@ -6,6 +6,7 @@ import type {
   FilterParams,
   ActiveFilters,
   OnChangeFilterParams,
+  OrderByEnum,
 } from './filter/FilterParametersType';
 
 const defaultFilterParams = {
@@ -22,29 +23,44 @@ const defaultActiveFilters = {
   isStarsFilterActive: false,
   isMinScoreActive: false,
   isHotelFacilitiesActive: false,
+  isOrderFilterActive: false,
 };
 
 const { Consumer, Provider: ContextProvider } = React.createContext({
   filterParams: defaultFilterParams,
   activeFilters: defaultActiveFilters,
+  orderBy: null,
   actions: {
     setFilter: () => {},
   },
 });
 
 type Props = {|
-  children: React.Node,
+  +children: React.Node,
 |};
 
 type State = {|
   filterParams: FilterParams,
   activeFilters: ActiveFilters,
+  orderBy: null | OrderByEnum,
   actions: {|
-    setFilter: OnChangeFilterParams => void,
+    +setFilter: OnChangeFilterParams => void,
   |},
 |};
 
 class Provider extends React.Component<Props, State> {
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      filterParams: defaultFilterParams,
+      activeFilters: defaultActiveFilters,
+      orderBy: null,
+      actions: {
+        setFilter: this.setFilter,
+      },
+    };
+  }
   setActiveFilters = () => {
     this.setState(state => {
       const {
@@ -62,30 +78,24 @@ class Provider extends React.Component<Props, State> {
           isStarsFilterActive: starsRating.length > 0,
           isMinScoreActive: minScore !== null,
           isHotelFacilitiesActive: hotelFacilities.length > 0,
+          isOrderFilterActive: state.orderBy != null,
         },
       };
     });
   };
 
-  setFilter = (action: OnChangeFilterParams) => {
-    this.setState(
-      state => ({
+  setFilter = ({ orderBy, ...action }: OnChangeFilterParams) => {
+    this.setState(state => {
+      const nextOrderBy = orderBy === undefined ? state.orderBy : orderBy;
+      return {
         ...state,
         filterParams: {
           ...state.filterParams,
           ...action,
         },
-      }),
-      this.setActiveFilters,
-    );
-  };
-
-  state = {
-    filterParams: defaultFilterParams,
-    activeFilters: defaultActiveFilters,
-    actions: {
-      setFilter: this.setFilter,
-    },
+        orderBy: nextOrderBy,
+      };
+    }, this.setActiveFilters);
   };
 
   render = () => (
diff --git a/app/hotels/src/allHotels/NewAllHotelsSearch.js b/app/hotels/src/allHotels/NewAllHotelsSearch.js
index 5a3baba3..ef192923 100644
--- a/app/hotels/src/allHotels/NewAllHotelsSearch.js
+++ b/app/hotels/src/allHotels/NewAllHotelsSearch.js
@@ -67,7 +67,7 @@ class NewAllHotelsSearch extends React.Component<Props> {
     }
     return (
       <HotelsFilterContext.Consumer>
-        {({ filterParams }) => (
+        {({ orderBy, filterParams }) => (
           <PublicApiRenderer
             query={graphql`
               query NewAllHotelsSearchQuery(
@@ -96,6 +96,7 @@ class NewAllHotelsSearch extends React.Component<Props> {
               first: 50,
               options: {
                 currency,
+                orderBy,
               },
             }}
             render={this.renderAllHotelsSearchList}
diff --git a/app/hotels/src/filter/FilterParametersType.js b/app/hotels/src/filter/FilterParametersType.js
index 9200e9a6..3b54b574 100644
--- a/app/hotels/src/filter/FilterParametersType.js
+++ b/app/hotels/src/filter/FilterParametersType.js
@@ -9,6 +9,14 @@ export type FilterParams = {|
   minScore: number | null,
 |};
 
+export type OrderByEnum =
+  | 'DISTANCE'
+  | 'POPULARITY'
+  | 'PRICE'
+  | 'RANKING'
+  | 'REVIEW_SCORE'
+  | 'STARS';
+
 export type OnChangeFilterParams = {|
   starsRating?: number[],
   minPrice?: ?number,
@@ -16,6 +24,7 @@ export type OnChangeFilterParams = {|
   freeCancellation?: boolean,
   hotelFacilities?: string[],
   minScore?: ?number,
+  orderBy?: ?OrderByEnum,
 |};
 
 export type ActiveFilters = {|
@@ -23,4 +32,5 @@ export type ActiveFilters = {|
   isStarsFilterActive: boolean,
   isMinScoreActive: boolean,
   isHotelFacilitiesActive: boolean,
+  isOrderFilterActive: boolean,
 |};
diff --git a/app/hotels/src/filter/FilterStripe.js b/app/hotels/src/filter/FilterStripe.js
index d9871102..0c48e811 100644
--- a/app/hotels/src/filter/FilterStripe.js
+++ b/app/hotels/src/filter/FilterStripe.js
@@ -11,10 +11,12 @@ import PriceFilter from './price/PriceFilter';
 import FreeCancellationFilter from './freeCancellation/FreeCancellationFilter';
 import HotelFacilitiesFilter from './hotelFacilities/HotelFacilitiesFilter';
 import ScoreFilter from './score/ScoreFilter';
+import OrderFilter from './order/OrderFilter';
 import type {
   ActiveFilters,
   FilterParams,
   OnChangeFilterParams,
+  OrderByEnum,
 } from './FilterParametersType';
 import HotelsFilterContext from '../HotelsFilterContext';
 import Filters from './Filters';
@@ -24,6 +26,7 @@ type PropsWithContext = {|
   +activeFilters: ActiveFilters,
   +onChange: OnChangeFilterParams => void,
   +filter: FilterParams,
+  +orderBy: OrderByEnum | null,
 |};
 
 /**
@@ -58,6 +61,11 @@ class FilterStripe extends React.Component<PropsWithContext> {
           showsHorizontalScrollIndicator={false}
         >
           <Filters>
+            <OrderFilter
+              orderBy={this.props.orderBy}
+              isActive={this.props.activeFilters.isOrderFilterActive}
+              onChange={this.onChange}
+            />
             <StarsFilter
               stars={this.props.filter.starsRating}
               onChange={this.onChange}
@@ -115,12 +123,18 @@ export default function FilterStripeWithContext() {
     <HotelsContext.Consumer>
       {({ currency }) => (
         <HotelsFilterContext.Consumer>
-          {({ activeFilters, filterParams, actions: { setFilter } }) => (
+          {({
+            activeFilters,
+            filterParams,
+            orderBy,
+            actions: { setFilter },
+          }) => (
             <FilterStripe
               currency={currency}
               onChange={setFilter}
               filter={filterParams}
               activeFilters={activeFilters}
+              orderBy={orderBy}
             />
           )}
         </HotelsFilterContext.Consumer>
diff --git a/app/hotels/src/filter/order/OrderCheckbox.js b/app/hotels/src/filter/order/OrderCheckbox.js
new file mode 100644
index 00000000..3e2eb1b3
--- /dev/null
+++ b/app/hotels/src/filter/order/OrderCheckbox.js
@@ -0,0 +1,25 @@
+// @flow strict
+
+import * as React from 'react';
+import { Checkbox } from '@kiwicom/mobile-shared';
+
+import type { OrderByEnum } from '../FilterParametersType';
+
+type Props = {|
+  +isChecked: boolean,
+  +checkKey: OrderByEnum,
+  +onPress: (orderBy: OrderByEnum | null) => void,
+  +children: React.Node,
+|};
+
+export default class OrderCheckbox extends React.Component<Props> {
+  onPress = () => {
+    this.props.onPress(this.props.isChecked ? null : this.props.checkKey);
+  };
+
+  render = () => (
+    <Checkbox onPress={this.onPress} isChecked={this.props.isChecked}>
+      {this.props.children}
+    </Checkbox>
+  );
+}
diff --git a/app/hotels/src/filter/order/OrderFilter.js b/app/hotels/src/filter/order/OrderFilter.js
new file mode 100644
index 00000000..0c03ffa2
--- /dev/null
+++ b/app/hotels/src/filter/order/OrderFilter.js
@@ -0,0 +1,82 @@
+// @flow strict
+
+import * as React from 'react';
+import { Translation } from '@kiwicom/mobile-localization';
+import { Icon } from '@kiwicom/mobile-shared';
+
+import FilterButton from '../FilterButton';
+import type {
+  OnChangeFilterParams,
+  OrderByEnum,
+} from '../FilterParametersType';
+import OrderPopup from './OrderPopup';
+
+const orderByOptions = {
+  default: <Translation id="hotels_search.filter.order_by" />,
+  PRICE: <Translation id="hotels_search.filter.order_filter.price" />,
+  DISTANCE: <Translation id="hotels_search.filter.order_filter.distance" />,
+  STARS: <Translation id="hotels_search.filter.order_filter.stars" />,
+};
+
+type Props = {|
+  +isActive: boolean,
+  +onChange: OnChangeFilterParams => void,
+  +orderBy: OrderByEnum | null,
+|};
+
+type State = {|
+  isPopupOpen: boolean,
+|};
+
+export default class OrderFilter extends React.Component<Props, State> {
+  state = {
+    isPopupOpen: false,
+  };
+
+  getTitle = () => {
+    if (
+      this.props.orderBy === null ||
+      !orderByOptions.hasOwnProperty(this.props.orderBy)
+    ) {
+      return orderByOptions.default;
+    }
+    const order: string = this.props.orderBy;
+    return orderByOptions[order];
+  };
+
+  filterButtonClicked = () => {
+    if (this.props.isActive) {
+      this.props.onChange({ orderBy: null });
+    } else {
+      this.togglePopup();
+    }
+  };
+
+  handleSave = (orderBy: OrderByEnum | null) => {
+    this.props.onChange({ orderBy });
+    this.togglePopup();
+  };
+
+  togglePopup = () => {
+    this.setState(state => ({
+      isPopupOpen: !state.isPopupOpen,
+    }));
+  };
+
+  render = () => (
+    <React.Fragment>
+      <FilterButton
+        title={this.getTitle()}
+        isActive={this.props.isActive}
+        onPress={this.filterButtonClicked}
+        icon={<Icon name="format-line-spacing" size={18} />}
+      />
+      <OrderPopup
+        isVisible={this.state.isPopupOpen}
+        onClose={this.togglePopup}
+        onSave={this.handleSave}
+        orderBy={this.props.orderBy}
+      />
+    </React.Fragment>
+  );
+}
diff --git a/app/hotels/src/filter/order/OrderPopup.js b/app/hotels/src/filter/order/OrderPopup.js
new file mode 100644
index 00000000..5fc0fccc
--- /dev/null
+++ b/app/hotels/src/filter/order/OrderPopup.js
@@ -0,0 +1,96 @@
+// @flow strict
+
+import * as React from 'react';
+import { View } from 'react-native';
+import { ButtonPopup, Text, StyleSheet } from '@kiwicom/mobile-shared';
+import { Translation } from '@kiwicom/mobile-localization';
+import { defaultTokens } from '@kiwicom/mobile-orbit';
+import { SeparatorFullWidth } from '@kiwicom/mobile-navigation';
+
+import type { OrderByEnum } from '../FilterParametersType';
+import OrderCheckbox from './OrderCheckbox';
+
+const ordersOptions = [
+  {
+    key: 'PRICE',
+    title: <Translation id="hotels_search.filter.order_popup.price" />,
+  },
+  {
+    key: 'DISTANCE',
+    title: <Translation id="hotels_search.filter.order_popup.distance" />,
+  },
+  {
+    key: 'STARS',
+    title: <Translation id="hotels_search.filter.order_popup.stars" />,
+  },
+];
+
+type Props = {|
+  +onClose: () => void,
+  +onSave: (orderBy: OrderByEnum | null) => void,
+  +isVisible: boolean,
+  +orderBy: OrderByEnum | null,
+|};
+
+type State = {|
+  currentOrder: OrderByEnum | null,
+|};
+export default class OrderPopup extends React.Component<Props, State> {
+  state = {
+    currentOrder: this.props.orderBy,
+  };
+
+  onSave = () => {
+    this.props.onSave(this.state.currentOrder);
+  };
+
+  onClose = () => {
+    this.setState({ currentOrder: null });
+    this.props.onClose();
+  };
+
+  setOrder = (currentOrder: OrderByEnum | null) => {
+    this.setState({ currentOrder });
+  };
+
+  render = () => (
+    <ButtonPopup
+      buttonTitle={<Translation id="shared.button.save" />}
+      buttonCloseTitle={<Translation id="shared.button.close" />}
+      onSave={this.onSave}
+      onClose={this.onClose}
+      isVisible={this.props.isVisible}
+    >
+      <Text style={styles.title}>
+        <Translation id="hotels_search.filter.order_popup.title" />
+      </Text>
+      {ordersOptions.map(option => (
+        <React.Fragment key={option.key}>
+          <OrderCheckbox
+            onPress={this.setOrder}
+            isChecked={this.state.currentOrder === option.key}
+            checkKey={option.key}
+          >
+            {option.title}
+          </OrderCheckbox>
+          <View style={styles.separator}>
+            <SeparatorFullWidth color={defaultTokens.paletteInkLighter} />
+          </View>
+        </React.Fragment>
+      ))}
+    </ButtonPopup>
+  );
+}
+
+const styles = StyleSheet.create({
+  title: {
+    color: defaultTokens.colorHeading,
+    fontSize: 16,
+    fontWeight: '500',
+    paddingTop: 15,
+    paddingBottom: 10,
+  },
+  separator: {
+    marginEnd: -15,
+  },
+});
diff --git a/packages/localization/src/DefaultVocabulary.js b/packages/localization/src/DefaultVocabulary.js
index c0653a4f..bf497d96 100644
--- a/packages/localization/src/DefaultVocabulary.js
+++ b/packages/localization/src/DefaultVocabulary.js
@@ -44,6 +44,7 @@ const SharedVocabulary = {
   'shared.age_picker.done': 'Done',
   'shared.bar_popup.cancel': 'Cancel',
   'shared.button.close': 'Close',
+  'shared.button.save': 'Save',
   'shared.button.cancel': 'Cancel',
   'shared.button.delete': 'Delete',
   'shared.pdf_viewer.load_failed': 'Failed to load pdf',
diff --git a/packages/localization/src/vocabularies/Hotels.js b/packages/localization/src/vocabularies/Hotels.js
index 582f5c02..41f11d7f 100644
--- a/packages/localization/src/vocabularies/Hotels.js
+++ b/packages/localization/src/vocabularies/Hotels.js
@@ -59,6 +59,14 @@ export default {
   'hotels_search.filter.stars_popup.save': 'Save',
   'hotels_search.filter.stars_popup.title': 'Hotel stars',
   'hotels_search.filter.stars_popup.unrated': 'Unrated',
+  'hotels_search.filter.order_by': 'sort',
+  'hotels_search.filter.order_filter.price': 'price',
+  'hotels_search.filter.order_filter.distance': 'distance from center',
+  'hotels_search.filter.order_filter.stars': 'stars',
+  'hotels_search.filter.order_popup.price': 'Price',
+  'hotels_search.filter.order_popup.distance': 'Distance from center',
+  'hotels_search.filter.order_popup.title': 'Sort by',
+  'hotels_search.filter.order_popup.stars': 'Stars',
   'hotels_search.guests.adults': 'Adults:',
   'hotels_search.guests.age_control.less_than_one': 'Less than year',
   'hotels_search.guests.age_control.select': 'Please select',


Test patch:
diff --git a/app/hotels/src/__tests__/HotelsFilterContext.test.js b/app/hotels/src/__tests__/HotelsFilterContext.test.js
new file mode 100644
index 00000000..3a1c96d2
--- /dev/null
+++ b/app/hotels/src/__tests__/HotelsFilterContext.test.js
@@ -0,0 +1,45 @@
+// @flow
+
+import * as React from 'react';
+import renderer from 'react-test-renderer';
+
+import HotelsFilterContext from '../HotelsFilterContext';
+
+const getWrapper = () =>
+  renderer
+    .create(<HotelsFilterContext.Provider>{null}</HotelsFilterContext.Provider>)
+    .getInstance();
+
+it('sets filter', () => {
+  const wrapper = getWrapper();
+
+  wrapper.setFilter({ orderBy: 'PRICE' });
+  wrapper.setFilter({
+    starsRating: [1, 2],
+  });
+  wrapper.setFilter({
+    minPrice: 45,
+    maxPrice: 95,
+  });
+
+  expect(wrapper.state.filterParams.starsRating).toEqual([1, 2]);
+  expect(wrapper.state.filterParams.maxPrice).toEqual(95);
+  expect(wrapper.state.filterParams.minPrice).toEqual(45);
+  expect(wrapper.state.orderBy).toEqual('PRICE');
+
+  expect(wrapper.state.activeFilters.isStarsFilterActive).toBe(true);
+  expect(wrapper.state.activeFilters.isPriceFilterActive).toBe(true);
+  expect(wrapper.state.activeFilters.isOrderFilterActive).toBe(true);
+});
+
+it('sets orderBy filter inactive', () => {
+  const wrapper = getWrapper();
+
+  wrapper.setFilter({ orderBy: 'PRICE' });
+  expect(wrapper.state.orderBy).toEqual('PRICE');
+  expect(wrapper.state.activeFilters.isOrderFilterActive).toBe(true);
+
+  wrapper.setFilter({ orderBy: null });
+  expect(wrapper.state.orderBy).toEqual(null);
+  expect(wrapper.state.activeFilters.isOrderFilterActive).toBe(false);
+});