diff --git a/.gitignore b/.gitignore index 9ec207bc..d9adadca 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ static-files/ database/ frontend/build/ frontend/jest/ +site-media/ +openeats.sql servies/static-files/ servies/side-media/ diff --git a/api/v1/recipe/migrations/0012_auto_20180106_1113.py b/api/v1/recipe/migrations/0012_auto_20180106_1113.py new file mode 100644 index 00000000..908023b9 --- /dev/null +++ b/api/v1/recipe/migrations/0012_auto_20180106_1113.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2018-01-06 17:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recipe', '0011_auto_20171114_1543'), + ] + + operations = [ + migrations.AddField( + model_name='recipe', + name='public', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='recipe', + name='info', + field=models.TextField(blank=True, help_text='enter information about the recipe', verbose_name='info'), + ), + ] diff --git a/api/v1/recipe/models.py b/api/v1/recipe/models.py index 87553d77..2ef59353 100644 --- a/api/v1/recipe/models.py +++ b/api/v1/recipe/models.py @@ -57,6 +57,7 @@ class Recipe(models.Model): rating = models.IntegerField(_('rating'), help_text="rating of the meal", default=0) pub_date = models.DateTimeField(auto_now_add=True) update_date = models.DateTimeField(auto_now=True) + public = models.BooleanField(default=True) class Meta: ordering = ['-pub_date', 'title'] diff --git a/api/v1/recipe/urls.py b/api/v1/recipe/urls.py index 9d1c4e80..e3e9dbc0 100644 --- a/api/v1/recipe/urls.py +++ b/api/v1/recipe/urls.py @@ -9,7 +9,7 @@ # Create a router and register our viewsets with it. router = DefaultRouter(schema_title='Recipes') router.register(r'mini-browse', views.MiniBrowseViewSet) -router.register(r'recipes', views.RecipeViewSet) +router.register(r'recipes', views.RecipeViewSet, base_name='recipes') router.register(r'rating', views.RatingViewSet, base_name='rating') urlpatterns = [ diff --git a/api/v1/recipe/views.py b/api/v1/recipe/views.py index 8bda038b..7fb44e5d 100644 --- a/api/v1/recipe/views.py +++ b/api/v1/recipe/views.py @@ -22,12 +22,33 @@ class RecipeViewSet(viewsets.ModelViewSet): This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions. """ - queryset = Recipe.objects.all() serializer_class = serializers.RecipeSerializer permission_classes = (permissions.IsAuthenticatedOrReadOnly,) - filter_backends = (filters.DjangoFilterBackend, filters.SearchFilter) - filter_fields = ('course__slug', 'cuisine__slug', 'course', 'cuisine', 'title', 'rating') + filter_backends = (filters.DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter) search_fields = ('title', 'tags__title', 'ingredient_groups__ingredients__title') + ordering_fields = ('pub_date', 'title', 'rating', ) + + def get_queryset(self): + query = Recipe.objects + filter_set = {} + + # If user is anonymous, restrict recipes to public. + if not self.request.user.is_authenticated: + filter_set['public'] = True + + if 'cuisine__slug' in self.request.query_params: + filter_set['cuisine__in'] = Cuisine.objects.filter( + slug__in=self.request.query_params.get('cuisine__slug').split(',') + ) + + if 'course__slug' in self.request.query_params: + filter_set['course__in'] = Course.objects.filter( + slug__in=self.request.query_params.get('course__slug').split(',') + ) + if 'rating' in self.request.query_params: + filter_set['rating__in'] = self.request.query_params.get('rating').split(',') + + return query.filter(**filter_set) def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -69,18 +90,24 @@ class MiniBrowseViewSet(viewsets.mixins.ListModelMixin, serializer_class = serializers.MiniBrowseSerializer def list(self, request, *args, **kwargs): + # If user is anonymous, restrict recipes to public. + if self.request.user.is_authenticated: + qs = Recipe.objects.all() + else: + qs = Recipe.objects.filter(public=True) + # Get the limit from the request and the count from the DB. # Compare to make sure you aren't accessing more than possible. limit = int(request.query_params.get('limit')) - count = Recipe.objects.count() + count = qs.count() if limit > count: limit = count # Get all ids from the DB. - my_ids = Recipe.objects.values_list('id', flat=True) + my_ids = qs.values_list('id', flat=True) # Select a random sample from the DB. rand_ids = random.sample(my_ids, limit) - # set teh queryset to that random sample. + # set the queryset to that random sample. self.queryset = Recipe.objects.filter(id__in=rand_ids) return super(MiniBrowseViewSet, self).list(request, *args, **kwargs) @@ -91,20 +118,24 @@ class RatingViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): query = Recipe.objects - filter_set = {} + + # If user is anonymous, restrict recipes to public. + if not self.request.user.is_authenticated: + filter_set['public'] = True + if 'cuisine' in self.request.query_params: try: - filter_set['cuisine'] = Cuisine.objects.get( - slug=self.request.query_params.get('cuisine') + filter_set['cuisine__in'] = Cuisine.objects.filter( + slug__in=self.request.query_params.get('cuisine').split(',') ) except: return [] if 'course' in self.request.query_params: try: - filter_set['course'] = Course.objects.get( - slug=self.request.query_params.get('course') + filter_set['course__in'] = Course.objects.filter( + slug__in=self.request.query_params.get('course').split(',') ) except: return [] diff --git a/api/v1/recipe_groups/views.py b/api/v1/recipe_groups/views.py index 3a0bd952..9420cf70 100644 --- a/api/v1/recipe_groups/views.py +++ b/api/v1/recipe_groups/views.py @@ -41,18 +41,22 @@ class CuisineCountViewSet(viewsets.ModelViewSet): def get_queryset(self): query = Recipe.objects - filter_set = {} + + # If user is anonymous, restrict recipes to public. + if not self.request.user.is_authenticated: + filter_set['public'] = True + if 'course' in self.request.query_params: try: - filter_set['course'] = Course.objects.get( - slug=self.request.query_params.get('course') + filter_set['course__in'] = Course.objects.filter( + slug__in=self.request.query_params.get('course').split(',') ) except: return [] if 'rating' in self.request.query_params: - filter_set['rating'] = self.request.query_params.get('rating') + filter_set['rating__in'] = self.request.query_params.get('rating').split(',') if 'search' in self.request.query_params: query = get_search_results( @@ -94,18 +98,22 @@ class CourseCountViewSet(viewsets.ModelViewSet): def get_queryset(self): query = Recipe.objects - filter_set = {} + + # If user is anonymous, restrict recipes to public. + if not self.request.user.is_authenticated: + filter_set['public'] = True + if 'cuisine' in self.request.query_params: try: - filter_set['cuisine'] = Cuisine.objects.get( - slug=self.request.query_params.get('cuisine') + filter_set['cuisine__in'] = Cuisine.objects.filter( + slug__in=self.request.query_params.get('cuisine').split(',') ) except: return [] if 'rating' in self.request.query_params: - filter_set['rating'] = self.request.query_params.get('rating') + filter_set['rating__in'] = self.request.query_params.get('rating').split(',') if 'search' in self.request.query_params: query = get_search_results( diff --git a/docker-compose.yml b/docker-compose.yml index d5b07c25..989201dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '2' +version: '2.1' services: api: build: api/ diff --git a/docker-prod.yml b/docker-prod.yml index 4dd504e6..bdd84b7d 100644 --- a/docker-prod.yml +++ b/docker-prod.yml @@ -1,4 +1,4 @@ -version: '2' +version: '2.1' services: api: image: openeats/api @@ -8,7 +8,8 @@ services: - static-files:/code/static-files - site-media:/code/site-media depends_on: - - db + db: + condition: service_healthy env_file: env_prod.list node: @@ -20,6 +21,10 @@ services: env_prod.list db: image: mariadb + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + timeout: 20s + retries: 20 env_file: env_prod.list nginx: diff --git a/docker-stage.yml b/docker-stage.yml index 0e77e7d3..a53f564d 100644 --- a/docker-stage.yml +++ b/docker-stage.yml @@ -1,4 +1,4 @@ -version: '2' +version: '2.1' services: api: build: api/ @@ -8,7 +8,8 @@ services: - static-files:/code/static-files - site-media:/code/site-media depends_on: - - db + db: + condition: service_healthy env_file: env_stg.list node: @@ -22,6 +23,10 @@ services: env_stg.list db: image: mariadb + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + timeout: 20s + retries: 20 env_file: env_stg.list nginx: diff --git a/docs/creating_a_proxy_server_for_docker.md b/docs/Creating_a_proxy_server_for_docker.md similarity index 100% rename from docs/creating_a_proxy_server_for_docker.md rename to docs/Creating_a_proxy_server_for_docker.md diff --git a/docs/Running_the_App.md b/docs/Running_the_App.md index 9fb04991..f24eee72 100644 --- a/docs/Running_the_App.md +++ b/docs/Running_the_App.md @@ -2,8 +2,6 @@ The recommended way to run this app is with docker. You can install docker [here](https://www.docker.com/products/overview). If you are not familiar with docker you can read more about it on [their website](https://www.docker.com/what-docker). -If you are looking to run the app without docker, see the instructions [here](Running_without_Docker.md) - ### Running the app with docker for production If you are looking to run this in production, there is no need to clone the repo. @@ -12,7 +10,7 @@ First, create two files: - docker-prod.yml - This file can be found in the in the root directory of the repo. - env_prod.list - The settings file [sample_env_file_for_docker.list](sample_env_file_for_docker.list) can be used as an example. -The `docker-prod.yml` contains the list of images and commands to run the app. It come with an nginx reverse proxy that by default will run on port 80. You will most likely want to change the port that nginx runs on as well as use a fix tag for the image. By default all are set to latest. +The `docker-prod.yml` contains the list of images and commands to run the app. It comes with an nginx reverse proxy that by default will run on port 80. You will most likely want to change the port that nginx runs on as well as use a fix tag for the image. By default, all are set to latest. #### Configure the environment file Most of the settings in your `env_prod.list` can stay the same as `env_stg.list` that is in this repo. There are a few config settings that need to be changed for most configurations. See [Setting_up_env_file.md](Setting_up_env_file.md) for a complete description of the environment variables. @@ -59,5 +57,4 @@ If you want to add some test data you can load a few recipes and some news data. ./manage.py loaddata news_data.json ./manage.py loaddata recipe_data.json ./manage.py loaddata ing_data.json -./manage.py loaddata direction_data.json ``` diff --git a/docs/Taking_Backups.md b/docs/Taking_Backups.md new file mode 100644 index 00000000..36fffc96 --- /dev/null +++ b/docs/Taking_Backups.md @@ -0,0 +1,18 @@ +# Backing up Your Data + +The following commands can be used to take backups of your data. These commands are automatically run when you upgrade to a newer version as well. + + +#### Backing up recipes images: + +Replace `/dir/on/local/system/` with the location where you would like your images. +```sh +docker cp openeats_api_1:/code/site-media/ /dir/on/local/system/ +``` + +#### Backing up database: + +Places a sql dump of the database on your current working directory. +```sh +docker exec openeats_db_1 sh -c 'exec mysqldump openeats -uroot -p"$MYSQL_ROOT_PASSWORD"' > openeats.sql +``` diff --git a/frontend/modules/browse/actions/BrowseActions.js b/frontend/modules/browse/actions/BrowseActions.js deleted file mode 100644 index 268a86bb..00000000 --- a/frontend/modules/browse/actions/BrowseActions.js +++ /dev/null @@ -1,98 +0,0 @@ -import AppDispatcher from '../../common/AppDispatcher'; -import Api from '../../common/Api'; -import history from '../../common/history' -import DefaultFilters from '../constants/DefaultFilters' - -const BrowseActions = { - browseInit: function(query) { - let filter = DefaultFilters; - - if (Object.keys(query).length > 0) { - for (let key in query) { - filter[key] = query[key]; - } - } - - this.loadRecipes(filter); - this.loadCourses(filter); - this.loadCuisines(filter); - this.loadRatings(filter); - }, - - loadRecipes: function(filter) { - AppDispatcher.dispatch({ - actionType: 'REQUEST_LOAD_RECIPES', - filter: filter - }); - Api.getRecipes(this.processLoadedRecipes, filter); - window.scrollTo(0, 0); - }, - - updateURL: function(filter) { - // TODO: use https://github.com/sindresorhus/query-string - let encode_data = []; - for (let key in filter) { - if (filter[key]) { - encode_data.push( - encodeURIComponent(key) + '=' + encodeURIComponent(filter[key]) - ); - } - } - - let path = '/browse/'; - if (encode_data.length > 0) { - path += '?' + encode_data.join('&'); - } - - history.push(path); - }, - - processLoadedRecipes: function(err, res) { - AppDispatcher.dispatch({ - actionType: 'PROCESS_LOAD_RECIPES', - err: err, - res: res - }); - }, - - loadCourses: function(filter) { - AppDispatcher.dispatch({actionType: 'REQUEST_LOAD_COURSES'}); - Api.getCourses(this.processLoadedCourses, filter); - }, - - processLoadedCourses: function(err, res) { - AppDispatcher.dispatch({ - actionType: 'PROCESS_LOAD_COURSES', - err: err, - res: res - }) - }, - - loadCuisines: function(filter) { - AppDispatcher.dispatch({actionType: 'REQUEST_LOAD_CUISINES'}); - Api.getCuisines(this.processLoadedCuisines, filter); - }, - - processLoadedCuisines: function(err, res) { - AppDispatcher.dispatch({ - actionType: 'PROCESS_LOAD_CUISINES', - err: err, - res: res - }) - }, - - loadRatings: function(filter) { - AppDispatcher.dispatch({actionType: 'REQUEST_LOAD_RATINGS'}); - Api.getRatings(this.processLoadedRatings, filter); - }, - - processLoadedRatings: function(err, res) { - AppDispatcher.dispatch({ - actionType: 'PROCESS_LOAD_RATINGS', - err: err, - res: res - }) - } -}; - -module.exports = BrowseActions; \ No newline at end of file diff --git a/frontend/modules/browse/actions/FilterActions.js b/frontend/modules/browse/actions/FilterActions.js new file mode 100644 index 00000000..51a12f53 --- /dev/null +++ b/frontend/modules/browse/actions/FilterActions.js @@ -0,0 +1,96 @@ +import queryString from 'query-string' + +import { request } from '../../common/CustomSuperagent'; +import { serverURLs } from '../../common/config' +import FilterConstants from '../constants/FilterConstants' + +const parsedFilter = filter => { + let parsedFilters = {}; + for (let f in filter) { + if (!['limit', 'offset'].includes(f)) { + parsedFilters[f] = filter[f]; + } + } + return parsedFilters; +}; + +export const loadCourses = (filter) => { + return dispatch => { + dispatch({ + type: FilterConstants.BROWSE_FILTER_LOADING, + filterName: FilterConstants.BROWSE_FILTER_COURSE, + }); + + request() + .get(serverURLs.course_count) + .query(parsedFilter(filter)) + .then(res => ( + dispatch({ + type: FilterConstants.BROWSE_FILTER_LOAD, + filterName: FilterConstants.BROWSE_FILTER_COURSE, + qs: queryString.stringify(filter), + res: res.body.results + }) + )) + .catch(err => ( + dispatch({ + type: FilterConstants.BROWSE_FILTER_ERROR, + filterName: FilterConstants.BROWSE_FILTER_COURSE, + }) + )); + } +}; + +export const loadCuisines = (filter) => { + return dispatch => { + dispatch({ + type: FilterConstants.BROWSE_FILTER_LOADING, + filterName: FilterConstants.BROWSE_FILTER_CUISINE, + }); + + request() + .get(serverURLs.cuisine_count) + .query(parsedFilter(filter)) + .then(res => ( + dispatch({ + type: FilterConstants.BROWSE_FILTER_LOAD, + filterName: FilterConstants.BROWSE_FILTER_CUISINE, + qs: queryString.stringify(filter), + res: res.body.results + }) + )) + .catch(err => ( + dispatch({ + type: FilterConstants.BROWSE_FILTER_ERROR, + filterName: FilterConstants.BROWSE_FILTER_CUISINE, + }) + )); + } +}; + +export const loadRatings = (filter) => { + return dispatch => { + dispatch({ + type: FilterConstants.BROWSE_FILTER_LOADING, + filterName: FilterConstants.BROWSE_FILTER_RATING, + }); + + request() + .get(serverURLs.ratings) + .query(parsedFilter(filter)) + .then(res => ( + dispatch({ + type: FilterConstants.BROWSE_FILTER_LOAD, + filterName: FilterConstants.BROWSE_FILTER_RATING, + qs: queryString.stringify(filter), + res: res.body.results + }) + )) + .catch(err => ( + dispatch({ + type: FilterConstants.BROWSE_FILTER_ERROR, + filterName: FilterConstants.BROWSE_FILTER_RATING, + }) + )); + } +}; diff --git a/frontend/modules/browse/actions/SearchActions.js b/frontend/modules/browse/actions/SearchActions.js new file mode 100644 index 00000000..d4240782 --- /dev/null +++ b/frontend/modules/browse/actions/SearchActions.js @@ -0,0 +1,34 @@ +import queryString from 'query-string' + +import SearchConstants from '../constants/SearchConstants' +import { request } from '../../common/CustomSuperagent'; +import { serverURLs } from '../../common/config' + +export const loadRecipes = (filter) => { + return dispatch => { + dispatch({ type: SearchConstants.BROWSE_SEARCH_LOADING }); + + const map = { + 'cuisine': 'cuisine__slug', + 'course': 'course__slug' + }; + + let parsedFilter = {}; + for (let f in filter) { + if (filter[f] !== null) { + parsedFilter[f in map ? map[f] : f] = filter[f]; + } + } + + request() + .get(serverURLs.browse) + .query(parsedFilter) + .then(res => ( + dispatch({ + type: SearchConstants.BROWSE_SEARCH_RESULTS, + qs: queryString.stringify(filter), + res: res.body + }) + )); + } +}; diff --git a/frontend/modules/browse/components/Browse.js b/frontend/modules/browse/components/Browse.js deleted file mode 100644 index 0e3e5196..00000000 --- a/frontend/modules/browse/components/Browse.js +++ /dev/null @@ -1,233 +0,0 @@ -import React from 'react' -import classNames from 'classnames'; -import SmoothCollapse from 'react-smooth-collapse'; -import queryString from 'query-string'; -import Spinner from 'react-spinkit'; -import { - injectIntl, - IntlProvider, - defineMessages, - formatMessage -} from 'react-intl'; - -import { Filter } from './Filter' -import { SearchBar } from './SearchBar' -import ListRecipes from './ListRecipes' -import { Pagination } from './Pagination' -import BrowseActions from '../actions/BrowseActions'; -import BrowseStore from '../stores/BrowseStore'; -import documentTitle from '../../common/documentTitle' -import { CourseStore, CuisineStore, RatingStore } from '../stores/FilterStores'; - -require("./../css/browse.scss"); - -class Browse extends React.Component { - constructor(props) { - super(props); - - this.state = { - recipes: [], - total_recipes: 0, - show_mobile_filters: false, - filter: {}, - courses: [], - cuisines: [], - ratings: [] - }; - - this._onChangeRecipes = this._onChangeRecipes.bind(this); - this._onChangeCourses = this._onChangeCourses.bind(this); - this._onChangeCuisines = this._onChangeCuisines.bind(this); - this._onChangeRatings = this._onChangeRatings.bind(this); - this.doFilter = this.doFilter.bind(this); - this.reloadData = this.reloadData.bind(this); - this.toggleMobileFilters = this.toggleMobileFilters.bind(this); - } - - _onChangeRecipes() { - this.setState(BrowseStore.getState()); - } - - _onChangeCourses() { - this.setState({courses: CourseStore.getState()['data']}); - } - - _onChangeCuisines() { - this.setState({cuisines: CuisineStore.getState()['data']}); - } - - _onChangeRatings() { - this.setState({ratings: RatingStore.getState()['data']}); - } - - componentDidMount() { - BrowseStore.addChangeListener(this._onChangeRecipes); - CourseStore.addChangeListener(this._onChangeCourses); - CuisineStore.addChangeListener(this._onChangeCuisines); - RatingStore.addChangeListener(this._onChangeRatings); - - BrowseActions.browseInit(queryString.parse(this.props.location.search)); - } - - componentWillUnmount() { - documentTitle(); - BrowseStore.removeChangeListener(this._onChangeRecipes); - CourseStore.removeChangeListener(this._onChangeCourses); - CuisineStore.removeChangeListener(this._onChangeCuisines); - RatingStore.removeChangeListener(this._onChangeRatings); - } - - componentWillReceiveProps(nextProps) { - let query = queryString.parse(this.props.location.search); - let nextQuery = queryString.parse(nextProps.location.search); - if (query.offset !== nextQuery.offset) { - BrowseActions.loadRecipes(nextQuery); - } else if (query.offset !== nextQuery.offset) { - this.reloadData(nextQuery); - } else if (query.course !== nextQuery.course) { - this.reloadData(nextQuery); - } else if (query.cuisine !== nextQuery.cuisine) { - this.reloadData(nextQuery); - } else if (query.rating !== nextQuery.rating) { - this.reloadData(nextQuery); - } else if (query.search !== nextQuery.search) { - this.reloadData(nextQuery); - } - } - - reloadData(filters) { - BrowseActions.loadRecipes(filters); - BrowseActions.loadCourses(filters); - BrowseActions.loadCuisines(filters); - BrowseActions.loadRatings(filters); - } - - doFilter(name, value) { - // Get a deep copy of the filter state - let filters = JSON.parse(JSON.stringify(this.state.filter)); - if (value !== "") { - filters[name] = value; - } else { - delete filters[name]; - } - - if (name !== "offset") { - filters['offset'] = 0; - } - - BrowseActions.updateURL(filters) - } - - toggleMobileFilters() { - this.setState({show_mobile_filters: !this.state.show_mobile_filters}); - } - - render() { - const {formatMessage} = this.props.intl; - const messages = defineMessages({ - no_results: { - id: 'browse.no_results', - description: 'No results header', - defaultMessage: 'Sorry, there are no results for your search.', - } - }); - documentTitle(this.props.intl.messages['nav.recipes']); - - let header = ( - - Show Filters - - - ); - if (this.state.show_mobile_filters) { - header = ( - - Hide Filters - - - ); - } - - let filters = ( -
-
- -
-
- -
-
- -
-
- ); - - return ( -
-
-
-
- { filters } -
- -
- { header } -
-
- - { filters } - -
-
-
-
- -
-
- { - this.state.recipes === undefined || this.state.recipes.length == 0 ? -
-

{ formatMessage(messages.no_results) }

- -
- : - - } -
-
-
- -
-
-
-
-
- ); - } -} - -module.exports = injectIntl(Browse); \ No newline at end of file diff --git a/frontend/modules/browse/components/Filter.js b/frontend/modules/browse/components/Filter.js index e6fa129d..0901d472 100644 --- a/frontend/modules/browse/components/Filter.js +++ b/frontend/modules/browse/components/Filter.js @@ -1,100 +1,48 @@ import React from 'react' -import { - injectIntl, - IntlProvider, - defineMessages, - formatMessage -} from 'react-intl'; +import PropTypes from 'prop-types' import classNames from 'classnames'; - -require("./../css/filter.scss"); - -class Filter extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: this.props.data || [], - loading: false, - filter: {} - }; - - this._onClick = this._onClick.bind(this); - } - - _onClick(event) { - event.preventDefault(); - this.props.doFilter(this.props.title, event.target.name); - } - - render() { - const {formatMessage} = this.props.intl; - const messages = defineMessages({ - filter_x: { - id: 'filter.filter_x', - description: 'Filter field', - defaultMessage: 'Filter {title}', - }, - clear_filter: { - id: 'filter.clear_filter', - description: 'Clear filter button', - defaultMessage: 'Clear filter', - }, - x_stars: { - id: 'filter.x_stars', - description: 'X Stars', - defaultMessage: '{rating, number} stars', - } - }); - - const items = this.props.data.map((item) => { - if (this.props.title == "rating") { - item.slug = item.rating; - item.title = formatMessage(messages.x_stars, {rating: item.rating}); - } - - if (item.total == 0) { - return null; +import { Link } from 'react-router-dom' + +const Filter = ({title, qsTitle, data, qs, multiSelect, cssClass, buildUrl}) => { + const items = data.map((item) => { + if (item.total == 0) { + return null; + } + + let active = false; + if (qs[qsTitle]) { + if (qs[qsTitle].split(',').includes(item.slug.toString())) { + active = true; } - - return ( - - { item.title } -
- { item.total } - - ); - }); + } return ( -
-

- { formatMessage(messages.filter_x, {title: this.props.title }) } - { this.props.title } -

- { items } - { this.props.filter[this.props.title] ? - - { formatMessage(messages.clear_filter) } - - : '' - } +
+ + { active ? : null } + { item.title } + { item.total ? { item.total } : '' } +
); - } -} - -module.exports.Filter = injectIntl(Filter); \ No newline at end of file + }); + + return ( +
+ { title } + { items } +
+ ); +}; + +Filter.propTypes = { + title: PropTypes.string.isRequired, + qsTitle: PropTypes.string.isRequired, + data: PropTypes.array.isRequired, + multiSelect: PropTypes.bool, + qs: PropTypes.object.isRequired, + cssClass: PropTypes.string, + buildUrl: PropTypes.func.isRequired, +}; + +export default Filter; \ No newline at end of file diff --git a/frontend/modules/browse/components/ListRecipes.js b/frontend/modules/browse/components/ListRecipes.js index 690b8acb..497ff77b 100644 --- a/frontend/modules/browse/components/ListRecipes.js +++ b/frontend/modules/browse/components/ListRecipes.js @@ -6,59 +6,57 @@ import Ratings from '../../recipe/components/Ratings'; require("./../css/list-recipes.scss"); -class ListRecipes extends React.Component { - getRecipeImage(recipe) { +const ListRecipes = ({ data, format }) => { + const getRecipeImage = (recipe) => { if (recipe.photo_thumbnail) { return recipe.photo_thumbnail; } else { const images = ['fish', 'fried-eggs', 'pizza', 'soup', 'steak']; return '/images/' + images[Math.floor(Math.random(0) * images.length)] + '.png'; } - } + }; - render() { - const recipes = this.props.data.map((recipe) => { - const link = '/recipe/' + recipe.id; - return ( -
-
-
- - { - -
-
-

{ recipe.title }

-

{ recipe.info }

-
- -

{ recipe.pub_date }

-
-
-
-
-
-

{ recipe.pub_date }

+ const recipes = data.map((recipe) => { + const link = '/recipe/' + recipe.id; + return ( +
+
+
+ + { + +
+
+

{ recipe.title }

+

{ recipe.info }

+
+

{ recipe.pub_date }

+
+
+

{ recipe.pub_date }

+ +
+
- ); - }); - - return ( -
- { recipes }
); - } -} + }); + + return ( +
+ { recipes } +
+ ); +}; ListRecipes.propTypes = { format: PropTypes.string.isRequired, data: PropTypes.array.isRequired }; -module.exports = ListRecipes; +export default ListRecipes; diff --git a/frontend/modules/browse/components/Loading.js b/frontend/modules/browse/components/Loading.js new file mode 100644 index 00000000..4b06854b --- /dev/null +++ b/frontend/modules/browse/components/Loading.js @@ -0,0 +1,18 @@ +import React from 'react' +import Spinner from 'react-spinkit'; + +const Loading = () => { + return ( +
+
+
+
+ +
+
+
+
+ ) +}; + +export default Loading; \ No newline at end of file diff --git a/frontend/modules/browse/components/NoResults.js b/frontend/modules/browse/components/NoResults.js new file mode 100644 index 00000000..4749bfbe --- /dev/null +++ b/frontend/modules/browse/components/NoResults.js @@ -0,0 +1,34 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + injectIntl, + defineMessages +} from 'react-intl'; + +const NoResults = ({intl}) => { + const messages = defineMessages({ + no_results: { + id: 'browse.no_results', + description: 'No results header', + defaultMessage: 'Sorry, there are no results for your search.', + } + }); + + return ( +
+
+
+
+

{ intl.formatMessage(messages.no_results) }

+
+
+
+
+ ) +}; + +NoResults.propTypes = { + intl: PropTypes.object +}; + +export default injectIntl(NoResults); \ No newline at end of file diff --git a/frontend/modules/browse/components/Pagination.js b/frontend/modules/browse/components/Pagination.js index cd9b95bb..bbadefaa 100644 --- a/frontend/modules/browse/components/Pagination.js +++ b/frontend/modules/browse/components/Pagination.js @@ -1,71 +1,54 @@ import React from 'react' -import { - injectIntl, - IntlProvider, - defineMessages, - formatMessage -} from 'react-intl'; +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { Link } from 'react-router-dom' -class Pagination extends React.Component { - constructor(props) { - super(props); +const Pagination = ({ offset, limit, count, buildUrl }) => { + offset = offset ? parseInt(offset) : 0; + limit = limit ? parseInt(limit) : 0; + count = count ? parseInt(count) : 0; + let next = offset + limit; + let previous = offset - limit; - this._onClick = this._onClick.bind(this); - } + const link = (title, offset, key, active) => ( +
  • + + { title } + +
  • + ); - _onClick(event) { - event.preventDefault(); - if (this.props.filter) { - this.props.filter('offset', parseInt(event.target.name)); - } - } + const numbers = (offset, limit, count) => { + let numbers = []; - render() { - let offset = this.props.offset ? parseInt(this.props.offset) : 0; - let limit = this.props.limit ? parseInt(this.props.limit) : 0; - let count = this.props.count ? parseInt(this.props.count) : 0; - let next = offset + limit; - let previous = offset - limit; + const min = 2, max = 5; + // Make sure we start at the min value + let start = offset - min < 1 ? 1 : offset - min; + // Make sure we start at the max value + start = start > count/limit-max ? count/limit-max : start; + // Only show data if we have results + start = start < 1 ? 1 : start; - const {formatMessage} = this.props.intl; - const messages = defineMessages({ - newer: { - id: 'pagination.newer', - description: 'Newer content link text', - defaultMessage: 'Newer', - }, - older: { - id: 'pagination.older', - description: 'Older content link text', - defaultMessage: 'Older', - } - }); + for (let i = start; i < count/limit && i < max + start; i++) { + numbers.push(link(i+1, limit*i, i+1, offset==limit*i)) + } + return numbers + }; - return ( -
      -
    • - { (previous >= 0) ? - - ← { formatMessage(messages.newer) } - - : '' - } -
    • -
    • - { (next < count) ? - - { formatMessage(messages.older) } → - - : '' - } -
    • + return ( +
      +
        + { (previous >= 0) ? link('←', previous, 'previous') : '' } + { link('1', 0, 'first', offset==0) } + { numbers(offset, limit, count) } + { (next < count) ? link('→', next, 'next') : '' }
      - ); - } -} +
      + ) +}; + +Pagination.propTypes = { + buildUrl: PropTypes.func.isRequired +}; -module.exports.Pagination = injectIntl(Pagination); +export default Pagination; diff --git a/frontend/modules/browse/components/Results.js b/frontend/modules/browse/components/Results.js new file mode 100644 index 00000000..f61080dc --- /dev/null +++ b/frontend/modules/browse/components/Results.js @@ -0,0 +1,36 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import ListRecipes from './ListRecipes' +import Pagination from './Pagination' + +const Results = ({ search, qs, defaults, buildUrl }) => { + return ( +
      +
      + +
      +
      +
      + +
      +
      +
      + ) +}; + +Results.propTypes = { + search: PropTypes.object, + qs: PropTypes.object, + buildUrl: PropTypes.func +}; + +export default Results; \ No newline at end of file diff --git a/frontend/modules/browse/components/Search.js b/frontend/modules/browse/components/Search.js new file mode 100644 index 00000000..f6ac4237 --- /dev/null +++ b/frontend/modules/browse/components/Search.js @@ -0,0 +1,59 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import SearchMenu from '../components/SearchMenu' +import SearchBar from '../components/SearchBar' +import Results from '../components/Results' +import NoResults from '../components/NoResults' +import Loading from '../components/Loading' + +const Search = ({ search, courses, cuisines, ratings, qs, qsString, buildUrl, doSearch, defaultFilters }) => { + if (Object.keys(search.results).length > 0) { + return ( +
      +
      +
      + +
      +
      + + { + search.loading ? + : + !search.results[qsString] || search.results[qsString].recipes.length == 0 ? + : + + } +
      +
      +
      + ); + } else { + return + } +}; + +// Results.propTypes = { +// search: PropTypes.object, +// qs: PropTypes.object, +// buildUrl: PropTypes.func +// }; + +export default Search; \ No newline at end of file diff --git a/frontend/modules/browse/components/SearchBar.js b/frontend/modules/browse/components/SearchBar.js index 2a7d49b7..e79e78fe 100644 --- a/frontend/modules/browse/components/SearchBar.js +++ b/frontend/modules/browse/components/SearchBar.js @@ -1,5 +1,6 @@ import React from 'react' import DebounceInput from 'react-debounce-input'; +import PropTypes from 'prop-types' import { injectIntl, IntlProvider, @@ -14,25 +15,21 @@ class SearchBar extends React.Component { this.state = { value: this.props.value || '' }; - - this._clearInput = this._clearInput.bind(this); - this._onChange = this._onChange.bind(this); - this._filter = this._filter.bind(this); } - _clearInput() { + _clearInput = () => { this.setState({ value: '' }, this._filter); - } + }; - _onChange(event) { + _onChange = (event) => { this.setState({ value: event.target.value }, this._filter); - } + }; - _filter() { - if (this.props.filter) { - this.props.filter('search', this.state.value); + _filter = () => { + if (this.props.doSearch) { + this.props.doSearch(this.state.value); } - } + }; componentWillReceiveProps(nextProps) { if (!nextProps.value) { @@ -43,7 +40,7 @@ class SearchBar extends React.Component { } shouldComponentUpdate(nextProps) { - return this.props.value !== nextProps.value; + return this.props.value !== nextProps.value || this.props.count !== nextProps.count; } render() { @@ -59,6 +56,11 @@ class SearchBar extends React.Component { description: 'SearchBar mobile label', defaultMessage: 'Search', }, + recipes: { + id: 'filter.recipes', + description: 'recipes', + defaultMessage: 'recipes', + }, input_placeholder: { id: 'searchbar.placeholder', description: 'SearchBar input placeholder', @@ -69,10 +71,8 @@ class SearchBar extends React.Component { let clearInput = ''; if (this.state.value) { clearInput = ( - - ) } @@ -81,8 +81,12 @@ class SearchBar extends React.Component {
      - { formatMessage(messages.search) }: - { formatMessage(messages.search_mobile) }: + + { formatMessage(messages.search) }: + + + { formatMessage(messages.search_mobile) }: + { clearInput } + + { this.props.count } { formatMessage(messages.recipes) } +
      ) } } -module.exports.SearchBar = injectIntl(SearchBar); +SearchBar.propTypes = { + value: PropTypes.string, + format: PropTypes.string, + doSearch: PropTypes.func, + intl: PropTypes.object, +}; + +export default injectIntl(SearchBar); diff --git a/frontend/modules/browse/components/SearchMenu.js b/frontend/modules/browse/components/SearchMenu.js new file mode 100644 index 00000000..dd660682 --- /dev/null +++ b/frontend/modules/browse/components/SearchMenu.js @@ -0,0 +1,243 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames'; +import PropTypes from 'prop-types' +import { + injectIntl, + defineMessages +} from 'react-intl'; + +import Filter from './Filter' + +require("./../css/filter.scss"); + +class SearchMenu extends React.Component { + constructor(props) { + super(props); + + this.state = { + showMenu: false, + } + } + + shouldComponentUpdate(nextProps, nextState) { + if ( + nextProps.loading || + ( + nextProps.courses === undefined && + nextProps.cuisines === undefined && + nextProps.rating === undefined && + !nextProps.error + ) + ) { + return false; + } + return true; + } + + toggleMenu = () => { + this.setState({showMenu: !this.state.showMenu}) + }; + + render() { + const { courses, cuisines, ratings, qs, buildUrl, intl } = this.props; + const messages = defineMessages({ + reset: { + id: 'filter.reset', + description: 'Filter reset', + defaultMessage: 'Reset', + }, + filter_course: { + id: 'filter.filter_course', + description: 'Filter field course', + defaultMessage: 'Courses', + }, + filter_cuisine: { + id: 'filter.filter_cuisine', + description: 'Filter field cuisine', + defaultMessage: 'Cuisines', + }, + filter_rating: { + id: 'filter.filter_rating', + description: 'Filter field rating', + defaultMessage: 'Ratings', + }, + filter_limit: { + id: 'filter.filter_limit', + description: 'Filter field limit', + defaultMessage: 'Recipes per Page', + }, + title: { + id: 'filter.title', + description: 'Title', + defaultMessage: 'Title', + }, + rating: { + id: 'filter.rating', + description: 'rating', + defaultMessage: 'Rating', + }, + pub_date: { + id: 'filter.pub_date', + description: 'pub_date', + defaultMessage: 'Created Date', + }, + filters: { + id: 'filter.filters', + description: 'Filters', + defaultMessage: 'Filters', + }, + show_filters: { + id: 'filter.show_filters', + description: 'Show Filters', + defaultMessage: 'Show Filters', + }, + hide_filters: { + id: 'filter.hide_filters', + description: 'Hide Filters', + defaultMessage: 'Hide Filters', + }, + filter_ordering: { + id: 'filter.filter_ordering', + description: 'Filter field ordering', + defaultMessage: 'Ordering', + }, + x_stars: { + id: 'filter.x_stars', + description: 'X Stars', + defaultMessage: '{rating, number} stars', + } + }); + + const activeFilter = Object.keys(qs).length !== 0; + + const reset = () => ( + + { intl.formatMessage(messages.reset) } + + ); + + const mobileReset = () => ( + + { intl.formatMessage(messages.reset) } + + ); + + const resetMargin = activeFilter ? "reset-margin" : ''; + let mobileText = ( + + { intl.formatMessage(messages.show_filters) } + + + ); + if (this.state.showMenu) { + mobileText = ( + + { intl.formatMessage(messages.hide_filters) } + + + ); + } + + const mobileHeader = ( +
      +
      +
      + { mobileText } +
      +
      + { activeFilter ? mobileReset() : '' } +
      +
      +
      + ); + + return ( +
      +
      + { mobileHeader } +
      +
      +
      + { intl.formatMessage(messages.filters) } +
      +
      +
      +
      + +
      +
      + +
      +
      + { + r.slug = r.rating; + r.title = intl.formatMessage(messages.x_stars, {rating: r.rating}); + return r; + }) : [] + } + qs={ qs } + multiSelect={ true } + buildUrl={ buildUrl } + /> +
      +
      + +
      +
      + +
      + { activeFilter ? reset() : '' } +
      +
      + ); + } +} + +SearchMenu.propTypes = { + qs: PropTypes.object.isRequired, + courses: PropTypes.array, + cuisines: PropTypes.array, + ratings: PropTypes.array, + buildUrl: PropTypes.func.isRequired, +}; + +export default injectIntl(SearchMenu); diff --git a/frontend/modules/browse/constants/FilterConstants.js b/frontend/modules/browse/constants/FilterConstants.js new file mode 100644 index 00000000..3da1683b --- /dev/null +++ b/frontend/modules/browse/constants/FilterConstants.js @@ -0,0 +1,9 @@ + +export default { + BROWSE_FILTER_LOAD: 'BROWSE_FILTER_LOAD', + BROWSE_FILTER_ERROR: 'BROWSE_FILTER_ERROR', + BROWSE_FILTER_LOADING: 'BROWSE_FILTER_LOADING', + BROWSE_FILTER_COURSE: 'BROWSE_FILTER_COURSE', + BROWSE_FILTER_CUISINE: 'BROWSE_FILTER_CUISINE', + BROWSE_FILTER_RATING: 'BROWSE_FILTER_RATING' +}; \ No newline at end of file diff --git a/frontend/modules/browse/constants/SearchConstants.js b/frontend/modules/browse/constants/SearchConstants.js new file mode 100644 index 00000000..5dbc28ed --- /dev/null +++ b/frontend/modules/browse/constants/SearchConstants.js @@ -0,0 +1,5 @@ + +export default { + BROWSE_SEARCH_RESULTS: 'BROWSE_SEARCH_RESULTS', + BROWSE_SEARCH_LOADING: 'BROWSE_SEARCH_LOADING', +}; \ No newline at end of file diff --git a/frontend/modules/browse/containers/Browse.js b/frontend/modules/browse/containers/Browse.js new file mode 100644 index 00000000..0c811c09 --- /dev/null +++ b/frontend/modules/browse/containers/Browse.js @@ -0,0 +1,161 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import queryString from 'query-string' +import { injectIntl } from 'react-intl'; + +import history from '../../common/history' +import Search from '../components/Search' + +import * as SearchActions from '../actions/SearchActions' +import * as FilterActions from '../actions/FilterActions' +import DefaultFilters from '../constants/DefaultFilters' +import documentTitle from '../../common/documentTitle' + +class Browse extends React.Component { + componentDidMount() { + documentTitle(this.props.intl.messages['nav.recipes']); + this.reloadData(queryString.parse(this.props.location.search)) + } + + componentWillUnmount() { + documentTitle(); + } + + componentWillReceiveProps(nextProps) { + let query = queryString.parse(this.props.location.search); + let nextQuery = queryString.parse(nextProps.location.search); + if (query.offset !== nextQuery.offset) { + this.reloadData(nextQuery); + } else if (query.limit !== nextQuery.limit) { + this.reloadData(nextQuery); + } else if (query.ordering !== nextQuery.ordering) { + this.reloadData(nextQuery); + } else if (query.offset !== nextQuery.offset) { + this.reloadData(nextQuery); + } else if (query.course !== nextQuery.course) { + this.reloadData(nextQuery); + } else if (query.cuisine !== nextQuery.cuisine) { + this.reloadData(nextQuery); + } else if (query.rating !== nextQuery.rating) { + this.reloadData(nextQuery); + } else if (query.search !== nextQuery.search) { + this.reloadData(nextQuery); + } + } + + reloadData(qs) { + if (!this.props.search.results[queryString.stringify(this.mergeDefaultFilters(qs))]) { + this.props.searchActions.loadRecipes(this.mergeDefaultFilters(qs)); + } + if (!this.props.courses.results[queryString.stringify(this.mergeDefaultFilters(qs))]) { + this.props.filterActions.loadCourses(this.mergeDefaultFilters(qs)); + } + if (!this.props.cuisines.results[queryString.stringify(this.mergeDefaultFilters(qs))]) { + this.props.filterActions.loadCuisines(this.mergeDefaultFilters(qs)); + } + if (!this.props.ratings.results[queryString.stringify(this.mergeDefaultFilters(qs))]) { + this.props.filterActions.loadRatings(this.mergeDefaultFilters(qs)); + } + } + + doSearch = (value) => { + console.log('hi'); + let qs = queryString.parse(this.props.location.search); + value !== "" ? qs['search'] = value : delete qs['search']; + let str = queryString.stringify(qs); + str = str ? '/browse/?' + str : '/browse/'; + history.push(str); + }; + + buildUrl = (name, value, multiSelect=false) => { + if (!name) return '/browse/'; + + let qs = queryString.parse(this.props.location.search); + + if (value !== "") { + if (qs[name] && multiSelect) { + let query = qs[name].split(','); + if (query.includes(value.toString())) { + if (query.length === 1) { + delete qs[name]; + } else { + let str = ''; + query.map(val => { val != value ? str += val + ',' : ''}); + qs[name] = str.substring(0, str.length - 1); + } + } else { + qs[name] = qs[name] + ',' + value; + } + } else { + qs[name] = value; + } + } else { + delete qs[name]; + } + + let str = queryString.stringify(qs); + return str ? '/browse/?' + str : '/browse/'; + }; + + mergeDefaultFilters = (query) => { + let filter = {}; + + if (Object.keys(DefaultFilters).length > 0) { + for (let key in DefaultFilters) { + filter[key] = DefaultFilters[key]; + } + } + + if (Object.keys(query).length > 0) { + for (let key in query) { + filter[key] = query[key]; + } + } + + return filter + }; + + render() { + const qs = queryString.parse(this.props.location.search); + const qsString = queryString.stringify(this.mergeDefaultFilters(qs)); + return ( + + ) + } +} + +Browse.propTypes = { + search: PropTypes.object.isRequired, + courses: PropTypes.object.isRequired, + cuisines: PropTypes.object.isRequired, + ratings: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + filterActions: PropTypes.object.isRequired, + searchActions: PropTypes.object.isRequired, +}; + +const mapStateToProps = state => ({ + search: state.browse.search, + courses: state.browse.filters.courses, + cuisines: state.browse.filters.cuisines, + ratings: state.browse.filters.ratings, +}); + +const mapDispatchToProps = (dispatch, props) => ({ + filterActions: bindActionCreators(FilterActions, dispatch), + searchActions: bindActionCreators(SearchActions, dispatch), +}); + +export default injectIntl(connect( + mapStateToProps, + mapDispatchToProps +)(Browse)); diff --git a/frontend/modules/browse/components/MiniBrowse.js b/frontend/modules/browse/containers/MiniBrowse.js similarity index 55% rename from frontend/modules/browse/components/MiniBrowse.js rename to frontend/modules/browse/containers/MiniBrowse.js index b026d3fb..244b4101 100644 --- a/frontend/modules/browse/components/MiniBrowse.js +++ b/frontend/modules/browse/containers/MiniBrowse.js @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types'; import { request } from '../../common/CustomSuperagent'; -import ListRecipes from './ListRecipes' +import ListRecipes from '../components/ListRecipes' import { serverURLs } from '../../common/config' require("./../css/browse.scss"); @@ -13,25 +13,12 @@ class MiniBrowse extends React.Component { this.state = { data: this.props.data || [] }; - - this.loadRecipesFromServer = this.loadRecipesFromServer.bind(this); - } - - loadRecipesFromServer(url) { - var base_url = serverURLs.mini_browse + url; - request() - .get(base_url) - .end((err, res) => { - if (!err && res) { - this.setState({data: res.body.results}); - } else { - console.error(base_url, err.toString()); - } - }) } componentDidMount() { - this.loadRecipesFromServer(this.props.qs); + request() + .get(serverURLs.mini_browse + this.props.qs) + .then(res => { this.setState({data: res.body.results}) }) } render() { @@ -46,4 +33,4 @@ MiniBrowse.propTypes = { qs: PropTypes.string.isRequired }; -module.exports = MiniBrowse; +export default MiniBrowse; diff --git a/frontend/modules/browse/css/browse.scss b/frontend/modules/browse/css/browse.scss index 12596e47..16ab8c2b 100644 --- a/frontend/modules/browse/css/browse.scss +++ b/frontend/modules/browse/css/browse.scss @@ -2,6 +2,13 @@ .search-bar { margin-top: 20px; + .search-clear { + background-color: #ffffff; + &:hover { + background-color: #eeeeee; + cursor: pointer; + } + } } .no-results { diff --git a/frontend/modules/browse/css/filter.scss b/frontend/modules/browse/css/filter.scss index f6c3f315..5769b28d 100644 --- a/frontend/modules/browse/css/filter.scss +++ b/frontend/modules/browse/css/filter.scss @@ -1,17 +1,50 @@ @import "../../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_variables.scss"; +.filter-title { + margin-top: 20px; + background-color: #eee; + padding: 6px; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; + border-top: 1px solid #ccc; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + color: #555555; +} .sidebar { - margin-top: 20px; + border: 1px solid #ccc; + padding-top: 10px; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + .reset-search { + margin-left: 10px; + margin-bottom: 10px; + } .filter { .list-group-item { - padding: 7px; + border: 0; + padding: 3px 7px; + color: #337ab7; .clear { clear: both; } + .glyphicon { + margin-right: 5px; + } .badge { display: block; - margin-top: -20px; + color: #fff; + background-color: #777777; + } + &.active { + background-color: #ffffff; + color: #337ab7; + font-weight: bold; + &:hover { + background-color: #eeeeee; + color: #135a97; + } } } .clear-filter { @@ -38,6 +71,20 @@ border-top-left-radius: 5px; border-top-right-radius: 5px; padding: 10px; + .filter-header { + .reset-margin { + margin-right: 60px; + } + } + .filter-header-clear { + float: right; + margin-top: -30px; + .clear-filter-mobile { + position: relative; + left: 10px; + padding: 9px; + } + } } .sidebar { border: 1px solid #ddd; @@ -70,4 +117,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/modules/browse/reducers/FilterReducer.js b/frontend/modules/browse/reducers/FilterReducer.js new file mode 100644 index 00000000..d7ee6e61 --- /dev/null +++ b/frontend/modules/browse/reducers/FilterReducer.js @@ -0,0 +1,36 @@ +import { combineReducers } from 'redux' +import FilterConstants from '../constants/FilterConstants' + +function createFilterWithNamedType(filterName = '') { + return function filter(state = { results: {}, loading: false, error: false }, action) { + if (action.filterName !== filterName) { + return state; + } + + switch (action.type) { + case FilterConstants.BROWSE_FILTER_ERROR: + return { ...state, error: true }; + case FilterConstants.BROWSE_FILTER_LOADING: + return { ...state, loading: true }; + case FilterConstants.BROWSE_FILTER_LOAD: + let newFilter = {}; + newFilter[action.qs] = action.res; + + return { + results: { ...state.results, ...newFilter }, + loading: false, + error: false + }; + default: + return state; + } + } +} + +const filters = combineReducers({ + courses: createFilterWithNamedType(FilterConstants.BROWSE_FILTER_COURSE), + cuisines: createFilterWithNamedType(FilterConstants.BROWSE_FILTER_CUISINE), + ratings: createFilterWithNamedType(FilterConstants.BROWSE_FILTER_RATING), +}); + +export default filters diff --git a/frontend/modules/browse/reducers/Reducer.js b/frontend/modules/browse/reducers/Reducer.js new file mode 100644 index 00000000..343c1c28 --- /dev/null +++ b/frontend/modules/browse/reducers/Reducer.js @@ -0,0 +1,11 @@ +import { combineReducers } from 'redux' + +import { default as filters } from './FilterReducer' +import { default as search } from './SearchReducer' + +const browse = combineReducers({ + search, + filters, +}); + +export default browse diff --git a/frontend/modules/browse/reducers/SearchReducer.js b/frontend/modules/browse/reducers/SearchReducer.js new file mode 100644 index 00000000..4d0929d1 --- /dev/null +++ b/frontend/modules/browse/reducers/SearchReducer.js @@ -0,0 +1,23 @@ +import SearchConstants from '../constants/SearchConstants' + +function search(state = { results: {}, loading: true }, action) { + switch (action.type) { + case SearchConstants.BROWSE_SEARCH_LOADING: + return { ...state, loading: true }; + case SearchConstants.BROWSE_SEARCH_RESULTS: + let newSearch = {}; + newSearch[action.qs] = { + recipes: action.res.results, + totalRecipes: action.res.count + }; + + return { + results: { ...state.results, ...newSearch }, + loading: false + }; + default: + return state; + } +} + +export default search diff --git a/frontend/modules/browse/stores/BrowseStore.js b/frontend/modules/browse/stores/BrowseStore.js deleted file mode 100644 index 54c23e6e..00000000 --- a/frontend/modules/browse/stores/BrowseStore.js +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { EventEmitter } from 'events'; -import AppDispatcher from '../../common/AppDispatcher'; - -class BrowseStore extends EventEmitter { - constructor(AppDispatcher) { - super(AppDispatcher); - - this.state = { - loading: true, - recipes: [], - filter: {}, - total_recipes: 0 - }; - - AppDispatcher.register(payload => { - switch(payload.actionType) { - case 'REQUEST_LOAD_RECIPES': - this.state.loading = true; - this.state.filter = payload.filter; - this.emitChange(); - break; - - case 'PROCESS_LOAD_RECIPES': - this.state.loading = false; - this.state.recipes = payload.res.body.results; - this.state.total_recipes = payload.res.body.count; - this.emitChange(); - break; - } - }); - } - - getState() { - return this.state; - } - - emitChange() { - this.emit('change'); - } - - addChangeListener(callback) { - this.on('change', callback); - } - - removeChangeListener(callback) { - this.removeListener('change', callback); - } -}; - -module.exports = new BrowseStore(AppDispatcher); \ No newline at end of file diff --git a/frontend/modules/browse/stores/FilterStores.js b/frontend/modules/browse/stores/FilterStores.js deleted file mode 100644 index 97310751..00000000 --- a/frontend/modules/browse/stores/FilterStores.js +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import { EventEmitter } from 'events'; -import AppDispatcher from '../../common/AppDispatcher'; -import Api from '../../common/Api'; - -class FilterStore extends EventEmitter { - constructor(AppDispatcher, name) { - super(AppDispatcher); - - this.state = { - loading: true, - data: [] - }; - - this.name = name; - - AppDispatcher.register(payload => { - switch (payload.actionType) { - case 'REQUEST_LOAD_' + this.name.toUpperCase() + 'S': - this.requestLoadData(payload); - break; - - case 'PROCESS_LOAD_' + this.name.toUpperCase() + 'S': - this.processLoadData(payload); - break; - } - - return true; - }); - } - - getState() { - return this.state; - } - - emitChange() { - this.emit('change'); - } - - addChangeListener(callback) { - this.on('change', callback); - } - - removeChangeListener(callback) { - this.removeListener('change', callback); - } - - processLoadData(action) { - this.state.loading = false; - this.state.data = action.res.body.results; - this.emitChange(); - } - - requestLoadData(action) { - this.state.loading = true; - this.emitChange(); - } -}; - -module.exports.CuisineStore = new FilterStore(AppDispatcher, 'cuisine'); -module.exports.CourseStore = new FilterStore(AppDispatcher, 'course'); -module.exports.RatingStore = new FilterStore(AppDispatcher, 'rating'); \ No newline at end of file diff --git a/frontend/modules/browse/tests/Loading.test.js b/frontend/modules/browse/tests/Loading.test.js new file mode 100644 index 00000000..2618d633 --- /dev/null +++ b/frontend/modules/browse/tests/Loading.test.js @@ -0,0 +1,11 @@ +import React from 'react'; +import Loading from '../components/Loading'; +import renderer from 'react-test-renderer'; + +test('Loading component test', () => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/frontend/modules/browse/tests/NoResults.test.js b/frontend/modules/browse/tests/NoResults.test.js new file mode 100644 index 00000000..b3f1d1ff --- /dev/null +++ b/frontend/modules/browse/tests/NoResults.test.js @@ -0,0 +1,11 @@ +import React from 'react'; +import NoResults from '../components/NoResults'; +import createComponentWithIntl from '../../../jest_mocks/createComponentWithIntl'; + +test('NoResults component test', () => { + const component = createComponentWithIntl( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/frontend/modules/browse/tests/__snapshots__/Loading.test.js.snap b/frontend/modules/browse/tests/__snapshots__/Loading.test.js.snap new file mode 100644 index 00000000..21fdcc45 --- /dev/null +++ b/frontend/modules/browse/tests/__snapshots__/Loading.test.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Loading component test 1`] = ` +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +`; diff --git a/frontend/modules/browse/tests/__snapshots__/NoResults.test.js.snap b/frontend/modules/browse/tests/__snapshots__/NoResults.test.js.snap new file mode 100644 index 00000000..754785e1 --- /dev/null +++ b/frontend/modules/browse/tests/__snapshots__/NoResults.test.js.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NoResults component test 1`] = ` +
      +
      +
      +
      +

      + Sorry, there are no results for your search. +

      +
      +
      +
      +
      +`; diff --git a/frontend/modules/common/Api.js b/frontend/modules/common/Api.js deleted file mode 100644 index 438de849..00000000 --- a/frontend/modules/common/Api.js +++ /dev/null @@ -1,67 +0,0 @@ -import { request } from './CustomSuperagent'; -import { serverURLs } from './config' - -class ApiClass { - getRecipes(callback, filter) { - const map = { - 'cuisine': 'cuisine__slug', - 'course': 'course__slug' - }; - - let parsed_filter = {}; - for (let f in filter) { - if (filter[f] !== null) { - parsed_filter[f in map ? map[f] : f] = filter[f]; - } - } - - request() - .get(serverURLs.browse) - .query(parsed_filter) - .end(callback); - } - - getCourses(callback, filter) { - let parsed_filter = {}; - for (let f in filter) { - if (!['limit', 'offset'].includes(f)) { - parsed_filter[f] = filter[f]; - } - } - - request() - .get(serverURLs.course_count) - .query(parsed_filter) - .end(callback); - } - - getCuisines(callback, filter) { - let parsed_filter = {}; - for (let f in filter) { - if (!['limit', 'offset'].includes(f)) { - parsed_filter[f] = filter[f]; - } - } - - request() - .get(serverURLs.cuisine_count) - .query(parsed_filter) - .end(callback); - } - - getRatings(callback, filter) { - let parsed_filter = {}; - for (let f in filter) { - if (!['limit', 'offset'].includes(f)) { - parsed_filter[f] = filter[f]; - } - } - - request() - .get(serverURLs.ratings) - .query(parsed_filter) - .end(callback); - } -} - -module.exports = new ApiClass(); \ No newline at end of file diff --git a/frontend/modules/common/AppDispatcher.js b/frontend/modules/common/AppDispatcher.js deleted file mode 100644 index 56da186f..00000000 --- a/frontend/modules/common/AppDispatcher.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (c) 2014-2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * AppDispatcher - * - * A singleton that operates as the central hub for application updates. - */ - -var Dispatcher = require('flux').Dispatcher; - -module.exports = new Dispatcher(); diff --git a/frontend/modules/common/components/FormComponents.js b/frontend/modules/common/components/FormComponents.js index 471063c6..fbdb8e68 100644 --- a/frontend/modules/common/components/FormComponents.js +++ b/frontend/modules/common/components/FormComponents.js @@ -177,7 +177,7 @@ class Checkbox extends BaseComponent { render() { return (
      -
      ) diff --git a/frontend/modules/common/css/checkbox.scss b/frontend/modules/common/css/checkbox.scss index 5b1f74cc..2064f9d8 100644 --- a/frontend/modules/common/css/checkbox.scss +++ b/frontend/modules/common/css/checkbox.scss @@ -1,10 +1,8 @@ -.check-container { +.check-container, .checkbox-control { display: block; position: relative; - margin-left: -30px; margin-bottom: 7px; cursor: pointer; - font-size: 18px; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -51,5 +49,12 @@ &:hover input ~ .checkmark { background-color: #ccc; } + .checklabel { + padding-left: 30px; + } } +.check-container { + margin-left: -30px; + font-size: 18px; +} diff --git a/frontend/modules/common/reducer.js b/frontend/modules/common/reducer.js index 103292f8..98e4e467 100644 --- a/frontend/modules/common/reducer.js +++ b/frontend/modules/common/reducer.js @@ -1,5 +1,6 @@ import { combineReducers } from 'redux' import { default as user } from '../account/reducers/LoginReducer' +import { default as browse } from '../browse/reducers/Reducer' import { default as list } from '../list/reducers/GroceryListReducer' import { default as recipe } from '../recipe/reducers/Reducer' import { default as recipeForm } from '../recipe_form/reducers/Reducer' @@ -7,6 +8,7 @@ import { default as recipeForm } from '../recipe_form/reducers/Reducer' const reducer = combineReducers({ list, user, + browse, recipe, recipeForm, }); diff --git a/frontend/modules/index.js b/frontend/modules/index.js index ce441e20..0023374f 100644 --- a/frontend/modules/index.js +++ b/frontend/modules/index.js @@ -20,7 +20,7 @@ import NotFound from './base/components/NotFound' import Login from './account/containers/Login' import News from './news/components/News' import List from './list/containers/List' -import Browse from './browse/components/Browse' +import Browse from './browse/containers/Browse' import Form from './recipe_form/containers/Form' import RecipeView from './recipe/components/RecipeView' diff --git a/frontend/modules/news/components/News.js b/frontend/modules/news/components/News.js index 7e95663f..29403f0b 100644 --- a/frontend/modules/news/components/News.js +++ b/frontend/modules/news/components/News.js @@ -9,7 +9,7 @@ import { } from 'react-intl' import { request } from '../../common/CustomSuperagent'; -import MiniBrowse from '../../browse/components/MiniBrowse' +import MiniBrowse from '../../browse/containers/MiniBrowse' import { serverURLs } from '../../common/config' import documentTitle from '../../common/documentTitle' diff --git a/frontend/modules/recipe/components/RecipeView.js b/frontend/modules/recipe/components/RecipeView.js index 5380a8d0..a7cf46da 100644 --- a/frontend/modules/recipe/components/RecipeView.js +++ b/frontend/modules/recipe/components/RecipeView.js @@ -2,7 +2,7 @@ import React from 'react' import PropTypes from 'prop-types' import Recipe from '../containers/Recipe' -import MiniBrowse from '../../browse/components/MiniBrowse' +import MiniBrowse from '../../browse/containers/MiniBrowse' const RecipeView = ({ match }) => (
      diff --git a/frontend/modules/recipe_form/actions/RecipeFormActions.js b/frontend/modules/recipe_form/actions/RecipeFormActions.js index 099ad2dc..1f0bad84 100644 --- a/frontend/modules/recipe_form/actions/RecipeFormActions.js +++ b/frontend/modules/recipe_form/actions/RecipeFormActions.js @@ -18,7 +18,7 @@ export const create = () => { return (dispatch) => { dispatch({ type: RecipeFormConstants.RECIPE_FORM_INIT, - data: { id: 0 }, + data: { id: 0, public: true }, }); } }; diff --git a/frontend/modules/recipe_form/components/RecipeForm.js b/frontend/modules/recipe_form/components/RecipeForm.js index c78da7e1..c40d8239 100644 --- a/frontend/modules/recipe_form/components/RecipeForm.js +++ b/frontend/modules/recipe_form/components/RecipeForm.js @@ -9,6 +9,7 @@ import { File, Select, TextArea, + Checkbox, } from '../../common/components/FormComponents' import IngredientBox from './IngredientBox' @@ -161,6 +162,11 @@ class RecipeForm extends React.Component { description: 'optional', defaultMessage: 'Optional', }, + public_label: { + id: 'recipe.create.public_label', + description: 'Recipe set public label', + defaultMessage: 'Public Recipe', + }, submit: { id: 'recipe.create.submit', description: 'Submit recipe button', @@ -325,6 +331,13 @@ class RecipeForm extends React.Component { change={ this.props.recipeFormActions.update } fetchRecipeList={ this.props.recipeListActions.fetchRecipeList } /> + { this.props.form.id ?