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 }

+
+
+

{ 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",