filter state management bits

This commit is contained in:
Bart Akeley 2017-11-11 20:15:19 -06:00
parent ba8eb7c1e3
commit e15c5b4df6
24 changed files with 305 additions and 170 deletions

View file

@ -1,2 +0,0 @@
printWidth: 100;
parser: flow;

View file

@ -1,7 +1,9 @@
// @flow
import { memoize } from 'ramda';
import FilterRecord from '../records/FilterRecord';
const BASE_URL = 'aretherecookies.herokuapp.com';
// const BASE_URL = '192.168.1.6:3000';
export type FoodItemsFilter = {
radius?: number,
@ -25,38 +27,42 @@ export type FoodItemsForLocation = {
fooditems: Array<RawFoodItem>,
};
export const getFoodItemsForLocation = memoize(
async ({ pos: { coords: { latitude, longitude } } }: { pos: Position }): Promise<FoodItemsForLocation> => {
try {
return fetch(`https://${BASE_URL}/fooditems`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
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;
try {
return fetch(`http://${BASE_URL}/fooditems`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
lat,
lng,
orderby,
filter: {
...(categories ? { categories } : {}),
radius,
},
body: JSON.stringify({
lat: latitude,
lng: longitude,
orderby: 'distance',
filter: {
radius: 10,
},
}),
})
.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: [],
}),
})
.then(res => res.json())
.then(json => ({
...json,
loading: false,
error: error,
};
}
error: null,
}));
} catch (error) {
console.error(error); // eslint-disable-line no-console
return {
orderby: 'distance',
filter: {},
fooditems: [],
loading: false,
error: error,
};
}
);
});

View file

@ -4,7 +4,7 @@ import { getCategoryText } from '../helpers/CategoryHelpers';
import { Picker, View } from 'react-native';
import theme from '../ui-theme';
import { pure } from 'recompose';
import { type Category, CATEGORIES } from '../records/FoodItemRecord';
import { type Category, CATEGORIES } from '../constants/CategoryConstants';
import debounce from '../helpers/debounce';
const { picker: { color: selectedColor } } = theme;
@ -13,16 +13,17 @@ const defaultColor = 'black';
const getItemColor = (selected, current) => (selected === current ? selectedColor : defaultColor);
/* eslint-disable react/display-name */
const renderItem = (selected: Category) => (item: Category) =>
<Picker.Item key={item} label={getCategoryText(item)} value={item} color={getItemColor(selected, item)} />;
const renderItem = (selected: Category) => (item: Category) => (
<Picker.Item key={item} label={getCategoryText(item)} value={item} color={getItemColor(selected, item)} />
);
type categoryPickerProps = { selected: Category, onValueChange: Function, style: Object };
const categoryPicker = pure(({ selected, onValueChange, style }: categoryPickerProps) =>
const categoryPicker = pure(({ selected, onValueChange, style }: categoryPickerProps) => (
<View style={style}>
<Picker selectedValue={selected} onValueChange={debounce(onValueChange)} style={{ flex: 1 }}>
{CATEGORIES.map(renderItem(selected))}
</Picker>
</View>
);
));
export default categoryPicker;

View file

@ -0,0 +1,49 @@
// @flow
import React from 'react';
import { View, TouchableOpacity, Text } from 'react-native';
import CheckBox from 'react-native-checkbox';
import Modal from './Modal';
// import { Icon } from 'react-native-material-ui';
import { withFilter } from '../enhancers/filterEnhancers';
import typeof FilterRecord from '../records/FilterRecord';
import { CATEGORIES, type Category } from '../constants/CategoryConstants';
import { getCategoryText } from '../helpers/CategoryHelpers';
// import { Set } from 'immutable';
type Props = {
isVisible: boolean,
onClose: () => void,
filter: FilterRecord,
setFilter: (f: FilterRecord) => void,
};
const FilterModal = withFilter(({ isVisible, onClose, filter, setFilter }: Props) => {
const { orderby, categories, radius } = filter;
const toggleCategory = category => checked => {
setFilter(
filter.update('categories', categories => {
return checked ? categories.delete(category) : categories.add(category);
})
);
};
return (
<Modal isVisible={isVisible}>
<TouchableOpacity onPress={onClose}>
<Text style={{ fontSize: 30, fontWeight: 'bold' }}>Filters</Text>
{CATEGORIES.map((category: Category) => (
<CheckBox
key={category}
style={{ margin: 20 }}
checked={categories.has(category)}
onChange={toggleCategory(category)}
label={getCategoryText(category)}
/>
))}
</TouchableOpacity>
</Modal>
);
});
export default FilterModal;

View file

@ -43,11 +43,7 @@ class FoodItemList extends Component {
const { filter, foodItemsSeq, renderFoodItem, limit } = this.props;
const getItems = R.compose(intoArray, limitBy(limit), sortByDistance, filterBy(filter));
const items = getItems(foodItemsSeq);
return (
<View style={{ flexShrink: 2 }}>
{items.map(renderFoodItem)}
</View>
);
return <View style={{ flexShrink: 2 }}>{items.map(renderFoodItem)}</View>;
}
}

View file

@ -6,31 +6,31 @@ import { pure } from 'recompose';
import theme from '../ui-theme';
import { Icon } from 'react-native-material-ui';
export default pure(({ children, isVisible }) =>
<Modal isVisible={isVisible}>
<View
style={{
flexDirection: 'column',
backgroundColor: 'white',
padding: 4,
justifyContent: 'center',
alignItems: 'stretch',
borderRadius: 4,
flexShrink: 1,
}}
>
{children}
</View>
</Modal>
);
export default pure(({ children, isVisible }) => {
return (
<Modal isVisible={isVisible} backdropColor="black" backdropOpacity={1.0}>
<View
style={{
flexDirection: 'column',
backgroundColor: 'white',
padding: 4,
justifyContent: 'center',
alignItems: 'stretch',
borderRadius: 4,
flexShrink: 1,
}}
>
{children}
</View>
</Modal>
);
});
export const TextButton = ({ text, onPress, style = {} }: { text: string, onPress: () => void, style: Object }) => {
return (
<TouchableOpacity onPress={onPress}>
<View style={{ flexShrink: 0, ...style }}>
<Text style={{ color: theme.palette.accentColor, padding: 12, fontSize: 17 }}>
{text}
</Text>
<Text style={{ color: theme.palette.accentColor, padding: 12, fontSize: 17 }}>{text}</Text>
</View>
</TouchableOpacity>
);

View file

@ -36,7 +36,7 @@ type PlaceTileProps = {
};
export default pure(({ place, foodItems }: PlaceTileProps) => {
if (!place) {
if (!place || foodItems.size === 0) {
return <View />;
}

View file

@ -4,7 +4,7 @@ import { getQuantityText } from '../helpers/QuantityHelpers';
import { Picker, View } from 'react-native';
import theme from '../ui-theme';
import { pure } from 'recompose';
import { type Quantity, QUANTITIES } from '../records/FoodItemRecord';
import { type Quantity, QUANTITIES } from '../constants/QuantityConstants';
import debounce from '../helpers/debounce';
const { picker: { color: selectedColor } } = theme;
@ -13,21 +13,22 @@ const defaultColor = 'black';
const getItemColor = (selected, current) => (selected === current ? selectedColor : defaultColor);
/* eslint-disable react/display-name */
const renderQuantityItem = (selectedQuantity: Quantity) => (quantity: Quantity) =>
const renderQuantityItem = (selectedQuantity: Quantity) => (quantity: Quantity) => (
<Picker.Item
key={quantity}
label={getQuantityText(quantity)}
value={quantity}
color={getItemColor(selectedQuantity, quantity)}
/>;
/>
);
type QuantityPickerProps = { quantity: Quantity, onValueChange: Function, style: Object };
const QuantityPicker = pure(({ quantity, onValueChange, style }: QuantityPickerProps) =>
const QuantityPicker = pure(({ quantity, onValueChange, style }: QuantityPickerProps) => (
<View style={style}>
<Picker selectedValue={quantity} onValueChange={debounce(onValueChange)} style={{ flex: 1 }}>
{QUANTITIES.map(renderQuantityItem(quantity))}
</Picker>
</View>
);
));
export default QuantityPicker;

View file

@ -0,0 +1,11 @@
// @flow
export const CATEGORY_BEVERAGES: 'beverages' = 'beverages';
export const CATEGORY_DESSERTS: 'desserts' = 'desserts';
export const CATEGORY_ENTREES: 'entrees' = 'entrees';
export const CATEGORY_OTHER: 'other' = 'other';
export type Category =
| typeof CATEGORY_BEVERAGES
| typeof CATEGORY_DESSERTS
| typeof CATEGORY_ENTREES
| typeof CATEGORY_OTHER;
export const CATEGORIES = [CATEGORY_BEVERAGES, CATEGORY_DESSERTS, CATEGORY_ENTREES, CATEGORY_OTHER];

View file

@ -0,0 +1,7 @@
// @flow
export const QUANTITY_NONE: 'none' = 'none';
export const QUANTITY_FEW: 'few' = 'few';
export const QUANTITY_MANY: 'many' = 'many';
export const QUANTITY_LOTS: 'lots' = 'lots';
export type Quantity = typeof QUANTITY_NONE | typeof QUANTITY_FEW | typeof QUANTITY_MANY | typeof QUANTITY_LOTS;
export const QUANTITIES = [QUANTITY_NONE, QUANTITY_FEW, QUANTITY_LOTS, QUANTITY_MANY];

View file

@ -0,0 +1,13 @@
// @flow
import mapPropsStream from 'recompose/mapPropsStream';
import Filter$, { emitter } from '../streams/FilterStream';
export const withFilter = mapPropsStream(props$ => {
return props$.combineLatest(Filter$, (props, filter) => {
return {
...props,
filter,
setFilter: emitter,
};
});
});

View file

@ -1,10 +1,6 @@
// @flow
import FoodItemRecord, {
type Category,
CATEGORY_BEVERAGES,
CATEGORY_DESSERTS,
CATEGORY_ENTREES,
} from '../records/FoodItemRecord';
import FoodItemRecord from '../records/FoodItemRecord';
import { type Category, CATEGORY_BEVERAGES, CATEGORY_DESSERTS, CATEGORY_ENTREES } from '../constants/CategoryConstants';
import { type Map } from 'immutable';
export const getCategoryText = (category: Category) => {

View file

@ -1,5 +1,5 @@
// @flow
import { type Quantity } from '../records/FoodItemRecord';
import { type Quantity } from '../constants/QuantityConstants';
export const getQuantityText = (quantity: Quantity) => {
switch (quantity) {

View file

@ -1,5 +1,5 @@
// @flow
import { compose, branch } from 'recompose';
import { compose, branch, withState } from 'recompose';
import FoodList from './FoodList';
import FoodMap from './FoodMap';
import { withRouterContext, withViewMode, withPushRoute } from '../enhancers/routeEnhancers';
@ -8,6 +8,7 @@ export default compose(
withRouterContext,
withViewMode,
withPushRoute,
withState('isFilterModalOpen', 'toggleFilterModal', false),
branch(({ viewMode }) => {
return viewMode === 'map';
}, FoodMap)

View file

@ -3,7 +3,8 @@ import React, { Component } from 'react';
import { Image, View } from 'react-native';
import theme from '../ui-theme';
import { StrongText, SubText } from '../components/ItemTile';
import { type FoodItem, type Quantity, QUANTITY_MANY } from '../records/FoodItemRecord';
import { type FoodItem } from '../records/FoodItemRecord';
import { type Quantity, QUANTITY_MANY } from '../constants/QuantityConstants';
import typeof PlaceRecord from '../records/PlaceRecord';
import { compose, pure } from 'recompose';
import IconButton from '../components/IconButton';
@ -70,14 +71,16 @@ export class FoodItemDetail extends Component {
return (
<View style={{ ...theme.page.container }}>
<View style={{ flex: 3 }}>
{viewableImages.size === 1 &&
<Image style={stretchedStyle} source={{ uri: viewableImages.get(0) }} />}
{viewableImages.size > 1 &&
{viewableImages.size === 1 && (
<Image style={stretchedStyle} source={{ uri: viewableImages.get(0) }} />
)}
{viewableImages.size > 1 && (
<Carousel autoplay={false} onAnimateNextPage={this.changeCurrentImage} style={stretchedStyle}>
{viewableImages.map(uri =>
{viewableImages.map(uri => (
<Image key={uri} style={{ flex: 1, resizeMode: 'stretch' }} source={{ uri }} />
)}
</Carousel>}
))}
</Carousel>
)}
<CountBadge currentImage={this.state.currentImage + 1} totalCount={viewableImages.size} />
</View>
<View style={{ flex: 1, marginBottom: 10, ...contentTileStyle }}>
@ -86,12 +89,8 @@ export class FoodItemDetail extends Component {
underlayColor={theme.itemTile.pressHighlightColor}
>
<View style={{ marginTop: 15 }}>
<StrongText>
{place.name}
</StrongText>
<SubText>
{place.address}
</SubText>
<StrongText>{place.name}</StrongText>
<SubText>{place.address}</SubText>
</View>
</Link>
</View>

View file

@ -6,6 +6,7 @@ import { ActionButton } from 'react-native-material-ui';
import { routeWithTitle } from '../helpers/RouteHelpers';
import FoodItemList from '../components/FoodItemList';
import typeof FoodItemRecord from '../records/FoodItemRecord';
import FilterModal from '../components/FilterModal';
import theme from '../ui-theme';
@ -16,6 +17,8 @@ export default class FoodList extends Component {
props: {
pushRoute: Object => void,
isFilterModalOpen: boolean,
toggleFilterModal: (val: boolean) => void,
};
addFoodItem = () => {
@ -23,13 +26,40 @@ export default class FoodList extends Component {
this.props.pushRoute(newRoute);
};
toggleFilterModal = () => {
const { isFilterModalOpen, toggleFilterModal } = this.props;
toggleFilterModal(!isFilterModalOpen);
};
onActionPressed = (action: string) => {
switch (action) {
case 'add':
this.addFoodItem();
break;
case 'filter':
this.toggleFilterModal();
break;
}
};
render() {
const { isFilterModalOpen } = this.props;
return (
<View style={theme.page.container}>
<ScrollView>
<FoodItemList renderFoodItem={renderFoodItem} />
</ScrollView>
<ActionButton icon="add" onPress={this.addFoodItem} />
<FilterModal isVisible={isFilterModalOpen} onClose={this.toggleFilterModal} />
<ActionButton
actions={[
{ icon: 'add', label: 'Add New', name: 'add' },
{ icon: 'filter-list', label: 'Filter', name: 'filter' },
]}
icon="add"
transition="speedDial"
onPress={this.onActionPressed}
/>
</View>
);
}

View file

@ -0,0 +1,12 @@
// @flow
import { Record } from 'immutable';
import { CATEGORIES } from '../constants/CategoryConstants';
import { Set } from 'immutable';
const FilterRecord = Record({
orderby: 'distance',
categories: Set(CATEGORIES),
radius: 20,
});
export default FilterRecord;

View file

@ -1,24 +1,8 @@
//@flow
import { fromJS, List, Record } from 'immutable';
import { type RawFoodItem } from '../apis/FoodItemsApi';
export const QUANTITY_NONE: 'none' = 'none';
export const QUANTITY_FEW: 'few' = 'few';
export const QUANTITY_MANY: 'many' = 'many';
export const QUANTITY_LOTS: 'lots' = 'lots';
export type Quantity = typeof QUANTITY_NONE | typeof QUANTITY_FEW | typeof QUANTITY_MANY | typeof QUANTITY_LOTS;
export const QUANTITIES = [QUANTITY_NONE, QUANTITY_FEW, QUANTITY_LOTS, QUANTITY_MANY];
export const CATEGORY_BEVERAGES: 'beverages' = 'beverages';
export const CATEGORY_DESSERTS: 'desserts' = 'desserts';
export const CATEGORY_ENTREES: 'entrees' = 'entrees';
export const CATEGORY_OTHER: 'other' = 'other';
export type Category =
| typeof CATEGORY_BEVERAGES
| typeof CATEGORY_DESSERTS
| typeof CATEGORY_ENTREES
| typeof CATEGORY_OTHER;
export const CATEGORIES = [CATEGORY_BEVERAGES, CATEGORY_DESSERTS, CATEGORY_ENTREES, CATEGORY_OTHER];
import { type Category, CATEGORY_DESSERTS } from '../constants/CategoryConstants';
import { type Quantity, QUANTITY_MANY } from '../constants/QuantityConstants';
export type FoodItem = {
id: ?string,

View file

@ -0,0 +1,13 @@
// @flow
import { Subject, Observable, ReplaySubject } from 'rxjs';
import FilterRecord from '../records/FilterRecord';
const multicaster = new ReplaySubject();
export const emitter = val => multicaster.next(val);
// Observable.from([new FilterRecord()]).subscribe(multicaster);
emitter(new FilterRecord());
export default multicaster;

View file

@ -3,10 +3,13 @@ import { createFoodItem } from '../records/FoodItemRecord';
import { setById } from '../helpers/ImmutableHelpers';
import { Map } from 'immutable';
import location$ from './LocationStream';
import { getFoodItemsForLocation, type FoodItemsForLocation } from '../apis/FoodItemsApi';
import { getFoodItems, type FoodItemsForLocation } from '../apis/FoodItemsApi';
import FilterSubject from './FilterStream';
import FilterRecord from '../records/FilterRecord';
export default location$
.mergeMap(pos => getFoodItemsForLocation({ pos }))
.combineLatest(FilterSubject)
.mergeMap(([loc, filter]: [Position, FilterRecord]) => getFoodItems({ loc, filter }))
.map(({ fooditems = [] }: FoodItemsForLocation) => {
return fooditems.map(createFoodItem).reduce(setById, new Map());
});

View file

@ -6,7 +6,7 @@ import { getPlaceDetails } from '../apis/PlaceDetailsApi';
import { memoize } from 'ramda';
import { Observable } from 'rxjs';
import typeof FoodItemRecord from '../records/FoodItemRecord';
import PlaceRecord, { type GooglePlaceObj } from '../records/PlaceRecord';
import { type GooglePlaceObj } from '../records/PlaceRecord';
import { setById } from '../helpers/ImmutableHelpers';
/**

View file

@ -1,45 +1,50 @@
{
"name": "AreThereCookies",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "node node_modules/react-native/local-cli/cli.js start",
"test": "jest",
"lint": "flow && eslint js",
"android": "react-native run-android"
},
"dependencies": {
"babel-preset-es2015": "^6.24.0",
"immutable": "^3.8.1",
"ramda": "^0.24.1",
"react": "~15.4.1",
"react-native": "~0.42.0",
"react-native-drawer": "^2.3.0",
"react-native-google-places": "^2.1.0",
"react-native-image-picker": "^0.26.3",
"react-native-looped-carousel": "^0.1.5",
"react-native-maps": "0.15.1",
"react-native-material-ui": "^1.7.0",
"react-native-modal": "^2.2.0",
"react-native-scrollable-tab-view": "^0.7.4",
"react-native-vector-icons": "^4.0.0",
"react-router-native": "^4.0.0",
"recompose": "^0.23.4",
"rxjs": "^5.4.2"
},
"devDependencies": {
"babel-eslint": "^7.1.1",
"babel-jest": "18.0.0",
"babel-preset-react-native": "1.9.1",
"eslint": "^3.14.1",
"eslint-plugin-react": "^6.9.0",
"eslint-plugin-react-native": "^2.2.1",
"flow-bin": "0.38",
"jest": "18.1.0",
"jshint": "^2.9.4",
"react-test-renderer": "15.4.2"
},
"jest": {
"preset": "react-native"
}
"name": "aretherecookies",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "node node_modules/react-native/local-cli/cli.js start",
"test": "jest",
"lint": "flow && eslint js",
"android": "react-native run-android"
},
"dependencies": {
"babel-preset-es2015": "^6.24.0",
"immutable": "^3.8.1",
"ramda": "^0.24.1",
"react": "~15.4.1",
"react-native": "~0.42.0",
"react-native-checkbox": "^2.0.0",
"react-native-drawer": "^2.3.0",
"react-native-google-places": "^2.1.0",
"react-native-image-picker": "^0.26.3",
"react-native-looped-carousel": "^0.1.5",
"react-native-maps": "0.15.1",
"react-native-material-ui": "^1.7.0",
"react-native-modal": "^2.2.0",
"react-native-scrollable-tab-view": "^0.7.4",
"react-native-vector-icons": "^4.0.0",
"react-router-native": "^4.0.0",
"recompose": "^0.23.4",
"rxjs": "^5.4.2"
},
"devDependencies": {
"babel-eslint": "^7.1.1",
"babel-jest": "18.0.0",
"babel-preset-react-native": "1.9.1",
"eslint": "^3.14.1",
"eslint-plugin-react": "^6.9.0",
"eslint-plugin-react-native": "^2.2.1",
"flow-bin": "0.38",
"jest": "18.1.0",
"jshint": "^2.9.4",
"react-test-renderer": "15.4.2"
},
"jest": {
"preset": "react-native"
},
"prettier": {
"lineWidth": 100,
"parser": "flow"
}
}

View file

@ -1,15 +1,19 @@
SELECT
*,
ST_AsGeoJSON(loc) as location,
ST_Distance(
loc,
ST_GeogFromText('SRID=4326;POINT(-97.7286718 30.3033267)')
) / 1609 as distance
FROM food_items
WHERE
ST_DWithin(
loc,
ST_GeogFromText('SRID=4326;POINT(-97.7286718 30.3033267)'),
10 * 1609
)
f.id AS id,
f.name AS name,
f.place_id AS place_id,
f.category AS category,
f.images AS images,
f.thumbImage AS thumbImage,
ST_AsGeoJSON(f.loc) AS location,
ST_Distance(f.loc, ST_SetSRID(ST_Point(-97.7286718, 30.3033267),4326)::geography) / 1609 AS distance,
q.quantity AS quantity,
q.date AS lastUpdated
FROM food_items f
LEFT OUTER JOIN (
SELECT food_item_id, quantity, MAX(date) AS date FROM quantities GROUP BY food_item_id, quantity
) q
ON f.id = q.food_item_id
WHERE ST_DWithin(loc, ST_SetSRID(ST_Point(-97.7286718, 30.3033267),4326)::geography, 20 * 1609)
AND f.category IN ('desserts', 'beverages', 'entrees', 'other')
ORDER BY distance ASC;

View file

@ -3479,7 +3479,7 @@ promise@^7.1.1:
dependencies:
asap "~2.0.3"
prop-types@15.5.10, prop-types@^15.5.4, prop-types@^15.5.8:
prop-types@15.5.10, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8:
version "15.5.10"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
dependencies:
@ -3545,6 +3545,12 @@ react-native-animatable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/react-native-animatable/-/react-native-animatable-1.2.0.tgz#fd279c6ee4b49161c6cc3b951ed7765b35a73467"
react-native-checkbox@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/react-native-checkbox/-/react-native-checkbox-2.0.0.tgz#453bbfd2e055a21e69ebe7842414a055d50ff449"
dependencies:
prop-types "^15.5.10"
react-native-drawer@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-native-drawer/-/react-native-drawer-2.3.0.tgz#a0369ec80ff0b61c9f152dbdea91fe76c843113a"