From e5e76ce8818c0714d5d72be134b51c224912a9eb Mon Sep 17 00:00:00 2001 From: Bart Akeley Date: Sun, 4 Feb 2018 13:14:37 -0600 Subject: [PATCH] create food item save button and stream --- js/apis/FoodItemsApi.js | 85 +++++++++++++++---------- js/components/FoodItemSaveBtn.js | 35 ++++++++++ js/components/TopToolbar.js | 4 ++ js/enhancers/createFoodItemEnhancers.js | 41 ++++++++++++ js/pages/CreateFoodItem.js | 41 ++++++++---- js/streams/CreateFoodItemStream.js | 19 ++++++ 6 files changed, 177 insertions(+), 48 deletions(-) create mode 100644 js/components/FoodItemSaveBtn.js create mode 100644 js/enhancers/createFoodItemEnhancers.js create mode 100644 js/streams/CreateFoodItemStream.js diff --git a/js/apis/FoodItemsApi.js b/js/apis/FoodItemsApi.js index 92f13c6..bb59d3e 100644 --- a/js/apis/FoodItemsApi.js +++ b/js/apis/FoodItemsApi.js @@ -2,6 +2,7 @@ import { memoize } from 'ramda'; import FilterRecord from '../records/FilterRecord'; import { BASE_URL } from '../constants/AppConstants'; +import FoodItemRecord from '../records/FoodItemRecord'; export type FoodItemsFilter = { radius?: number, @@ -26,42 +27,56 @@ export type FoodItemsForLocation = { fooditems: Array, }; -export const getFoodItems = memoize(async ({ loc, filter }: { loc: Position, filter: FilterRecord }): Promise< - FoodItemsForLocation -> => { - const { coords: { latitude: lat, longitude: lng } } = loc; - const { orderby, categories, radius } = filter; +export const getFoodItems = memoize( + async ({ + loc, + filter, + }: { + loc: Position, + filter: FilterRecord, + }): Promise => { + const { coords: { latitude: lat, longitude: lng } } = loc; + const { orderby, categories, radius } = filter; - try { - return fetch(`http://${BASE_URL}/fooditems`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - lat, - lng, - orderby, - filter: { - ...(categories ? { categories } : {}), - radius, + try { + return fetch(`http://${BASE_URL}/fooditems`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - }), - }) - .then(res => res.json()) - .then(json => ({ - ...json, + body: JSON.stringify({ + lat, + lng, + orderby, + filter: { + ...(categories ? { categories } : {}), + radius, + }, + }), + }) + .then(res => res.json()) + .then(json => ({ + ...json, + loading: false, + error: null, + })); + } catch (error) { + console.error(error); // eslint-disable-line no-console + return { + orderby: 'distance', + filter: {}, + fooditems: [], loading: false, - error: null, - })); - } catch (error) { - console.error(error); // eslint-disable-line no-console - return { - orderby: 'distance', - filter: {}, - fooditems: [], - loading: false, - error: error, - }; + error: error, + }; + } } -}); +); + +export const createFoodItem = (foodItem: FoodItemRecord) => { + return new Promise(resolve => { + setTimeout(() => { + resolve(foodItem); + }, 1000); + }); +}; diff --git a/js/components/FoodItemSaveBtn.js b/js/components/FoodItemSaveBtn.js new file mode 100644 index 0000000..5f718c1 --- /dev/null +++ b/js/components/FoodItemSaveBtn.js @@ -0,0 +1,35 @@ +// @flow +import React from 'react'; +import { Text, TouchableOpacity } from 'react-native'; +import { withCreateFoodItem } from '../enhancers/createFoodItemEnhancers'; +import compose from 'recompose/compose'; +import mapProps from 'recompose/mapProps'; + +const FoodItemSaveBtn = ({ + saveFoodItem, + loading, +}: { + saveFoodItem?: Function, + loading: boolean, +}) => { + const textStyle = { + color: 'white', + marginRight: 20, + fontWeight: 'bold', + opacity: loading ? 0.7 : 1, + }; + + return ( + + SAVE + + ); +}; + +export default compose( + withCreateFoodItem, + mapProps(({ saveFoodItem, loading }) => ({ + saveFoodItem, + loading, + })) +)(FoodItemSaveBtn); diff --git a/js/components/TopToolbar.js b/js/components/TopToolbar.js index 62a7258..adb4a19 100644 --- a/js/components/TopToolbar.js +++ b/js/components/TopToolbar.js @@ -5,6 +5,7 @@ import { type RoutingContextFlowTypes, routingContextPropTypes } from '../routes import { path } from 'ramda'; import queryString from 'query-string'; import { getSearch } from '../helpers/RouteHelpers'; +import FoodItemSaveBtn from '../components/FoodItemSaveBtn'; type Props = { title: ?string, @@ -48,6 +49,9 @@ class TopToolbar extends Component { case '/list': { return viewMode === 'map' ? 'list' : 'map'; } + case '/createFoodItem': { + return ; + } default: { return ''; } diff --git a/js/enhancers/createFoodItemEnhancers.js b/js/enhancers/createFoodItemEnhancers.js new file mode 100644 index 0000000..88b32c0 --- /dev/null +++ b/js/enhancers/createFoodItemEnhancers.js @@ -0,0 +1,41 @@ +// @flow +import mapPropsStream from 'recompose/mapPropsStream'; +import CreateFoodItem$, { emitter } from '../streams/CreateFoodItemStream'; +import FoodItemRecord from '../records/FoodItemRecord'; +import { createFoodItem } from '../apis/FoodItemsApi'; + +// todo real implementation somewhere +const isFoodItemValid = () => true; + +export const withCreateFoodItem = mapPropsStream(props$ => { + return props$.combineLatest(CreateFoodItem$, (props, state) => { + const { foodItem, loading, error } = state; + + const setFoodItem = (foodItem: FoodItemRecord) => emitter({ ...state, foodItem }); + const setLoading = (loading: boolean) => emitter({ ...state, loading }); + const setError = (error: Error) => emitter({ ...state, error }); + + const saveFoodItem = async () => { + if (isFoodItemValid(foodItem)) { + try { + setLoading(true); + await createFoodItem(foodItem); + } catch (error) { + // todo else surface a toast notification + setError(error); + } finally { + setLoading(false); + } + } + }; + + return { + ...props, + foodItem, + loading, + error, + setFoodItem, + saveFoodItem, + }; + }); +}); diff --git a/js/pages/CreateFoodItem.js b/js/pages/CreateFoodItem.js index 5bc269b..bda12fb 100644 --- a/js/pages/CreateFoodItem.js +++ b/js/pages/CreateFoodItem.js @@ -12,6 +12,8 @@ import { compose, branch, withState, withHandlers, renderComponent, mapProps } f import RNGooglePlaces from 'react-native-google-places'; import CategoryPicker from '../components/CategoryPicker'; import { ImageThumb, ImagePicker } from '../components/ImagePicker'; +import { withCreateFoodItem } from '../enhancers/createFoodItemEnhancers'; +import Spinner from 'react-native-loading-spinner-overlay'; type GooglePlaceObject = { placeID: string, @@ -31,18 +33,19 @@ const fieldNameStyle = { paddingLeft: 10, }; -const Field = ({ onPress, text = '' }: { onPress?: Function, text: string }) => +const Field = ({ onPress, text = '' }: { onPress?: Function, text: string }) => ( - - {text} - + {text} - ; + +); const openPlaceModal = (onChoosePlace: (place: GooglePlaceObject) => void) => () => { - RNGooglePlaces.openAutocompleteModal({ type: 'establishment' }).then(onChoosePlace).catch(error => { - throw error; - }); + RNGooglePlaces.openAutocompleteModal({ type: 'establishment' }) + .then(onChoosePlace) + .catch(error => { + throw error; + }); }; type Props = { @@ -56,12 +59,23 @@ type Props = { updatePlace: (place: GooglePlaceObject) => void, addImage: (uri: string) => void, setImagePreview: (index?: number) => void, + loading: boolean, }; const CreateFoodItem = (props: Props) => { - const { foodItem, toggleNameModal, setPropOfFoodItem, place, updatePlace, addImage, setImagePreview } = props; + const { + foodItem, + toggleNameModal, + setPropOfFoodItem, + place, + updatePlace, + addImage, + setImagePreview, + loading, + } = props; return ( + { /> - {foodItem.images.map((image, index) => + {foodItem.images.map((image, index) => ( setImagePreview(index)} /> - )} + ))} ); @@ -147,10 +161,11 @@ const updatePlace = ({ setPlace, foodItem, setFoodItem }: Props) => ({ ); }; -const toggleNameModal = ({ nameModalOpen, setNameModalOpen }) => () => setNameModalOpen(!nameModalOpen); +const toggleNameModal = ({ nameModalOpen, setNameModalOpen }) => () => + setNameModalOpen(!nameModalOpen); export default compose( - withState('foodItem', 'setFoodItem', new FoodItemRecord()), + withCreateFoodItem, withState('place', 'setPlace', new PlaceRecord()), withState('nameModalOpen', 'setNameModalOpen', false), withState('imagePreview', 'setImagePreview', -1), diff --git a/js/streams/CreateFoodItemStream.js b/js/streams/CreateFoodItemStream.js new file mode 100644 index 0000000..cdde05d --- /dev/null +++ b/js/streams/CreateFoodItemStream.js @@ -0,0 +1,19 @@ +// @flow +import { ReplaySubject } from 'rxjs'; +import FoodItemRecord from '../records/FoodItemRecord'; + +type CreateFoodItemState = { + foodItem: FoodItemRecord, + loading: boolean, + error?: ?Error, +}; + +const multicaster: ReplaySubject = new ReplaySubject(); + +export function emitter(val: CreateFoodItemState) { + multicaster.next(val); +} + +emitter({ foodItem: new FoodItemRecord(), loading: false, error: null }); + +export default multicaster;