diff --git a/api/v1/recipe/views.py b/api/v1/recipe/views.py
index a5505144..7fb44e5d 100644
--- a/api/v1/recipe/views.py
+++ b/api/v1/recipe/views.py
@@ -24,16 +24,31 @@ class RecipeViewSet(viewsets.ModelViewSet):
"""
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 self.request.user.is_authenticated:
- return Recipe.objects.all()
- else:
- return Recipe.objects.filter(public=True)
+ 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)
@@ -107,20 +122,20 @@ def get_queryset(self):
# If user is anonymous, restrict recipes to public.
if not self.request.user.is_authenticated:
- filter_set['public']=True
+ 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 614dca87..9420cf70 100644
--- a/api/v1/recipe_groups/views.py
+++ b/api/v1/recipe_groups/views.py
@@ -45,18 +45,18 @@ def get_queryset(self):
# If user is anonymous, restrict recipes to public.
if not self.request.user.is_authenticated:
- filter_set['public']=True
+ 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(
@@ -102,18 +102,18 @@ def get_queryset(self):
# If user is anonymous, restrict recipes to public.
if not self.request.user.is_authenticated:
- filter_set['public']=True
+ 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/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 }
+
+
+
- );
- });
-
- 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/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/package.json b/frontend/package.json
index 2411c89e..0c62b9bb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,9 +24,7 @@
"classnames": "^2.1.5",
"compression": "^1.6.2",
"express": "^4.14.0",
- "flux": "3.1.3",
"if-env": "^1.0.0",
- "keymirror": "^0.1.1",
"prop-types": "^15.5.10",
"query-string": "^5.0.0",
"react": "16.0.0",
@@ -37,7 +35,6 @@
"react-redux": "^5.0.6",
"react-router-bootstrap": "^0.24.4",
"react-router-dom": "^4.2.2",
- "react-smooth-collapse": "1.3.2",
"react-spinkit": "2.1.2",
"redux": "^3.7.2",
"redux-thunk": "^2.2.0",