Meals.jsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import React, { useEffect, useState } from 'react';
  2. import { makeStyles } from "@material-ui/styles";
  3. import { alpha, Box, Button, Collapse, Divider, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText, Typography } from "@material-ui/core";
  4. import { ExpandLess, ExpandMore, UnfoldLess, UnfoldMore } from "@material-ui/icons";
  5. import { useTranslation } from "react-i18next";
  6. import { fetchAndUpdateMealsFromUser } from "./meals.util";
  7. import useCategoryIcons from "./useCategoryIcons";
  8. import { bool, string } from "prop-types";
  9. import MealAvatar from "./MealAvatar";
  10. import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
  11. import SelectMealTags from "./SelectMealTags";
  12. import { useLocation, useNavigate } from "react-router-dom";
  13. const useStyles = makeStyles(theme => ({
  14. infoText: {
  15. textAlign: "center",
  16. margin: "3rem 2rem",
  17. fontFamily: "Neucha",
  18. fontSize: "1.3rem",
  19. lineHeight: "1.4rem",
  20. },
  21. category: props => ({
  22. backgroundColor: alpha(props.own ? theme.palette.primary.main : theme.palette.secondary.main, 0.1),
  23. '&:hover': {
  24. backgroundColor: alpha(props.own ? theme.palette.primary.main : theme.palette.secondary.main, 0.2),
  25. }
  26. }),
  27. listItemIcon: {
  28. fontSize: "1rem",
  29. color: theme.palette.text.primary,
  30. minWidth: '2rem',
  31. },
  32. nestedListItem: {
  33. paddingLeft: theme.spacing(4),
  34. },
  35. controlBox: {
  36. height: '50px',
  37. padding: '5px',
  38. },
  39. filterBox: {
  40. padding: '10px',
  41. marginTop: '-15px',
  42. },
  43. filterTags: {
  44. borderRadius: 0,
  45. marginTop: 0,
  46. },
  47. optionRowButton: {
  48. margin: '5px 0.5rem 5px',
  49. padding: '0 5px',
  50. }
  51. }));
  52. /** Content of page that displays all meals of a given use and opens their detail views on click.
  53. * If meals belong to logged in user, editing will be allowed.
  54. * todo: keep filters if meal is visualized
  55. * todo: add search in addition to filters
  56. * todo: restructure to have logic in a wrapper component, so that refresh can be triggered
  57. * todo: Snackbar visualization */
  58. const Meals = (props) => {
  59. const classes = useStyles(props);
  60. const { t } = useTranslation();
  61. let navigate = useNavigate();
  62. const { pathname } = useLocation();
  63. const { own, userId } = props;
  64. const [, updateState] = useState();
  65. const forceUpdate = React.useCallback(() => updateState({}), []);
  66. const [isFilterOpen, setIsFilterOpen] = useState(false);
  67. const [filterTags, setFilterTags] = useState([]);
  68. const [meals, setMeals] = useState([]);
  69. const [filteredMeals, setFilteredMeals] = useState([]);
  70. const [mealsByCategory,] = useState(new Map());
  71. const [isCategoryOpen, setIsCategoryOpen] = useState({});
  72. const [allCategoriesClosed, setAllCategoriesClosed] = useState(false);
  73. const [categoryIcons, fireIconReload] = useCategoryIcons(userId);
  74. const [emptyListFound, setEmptyListFound] = useState(false);
  75. const updateMealsCallback = (mealsFound) => {
  76. setMeals(mealsFound);
  77. setFilteredMeals(mealsFound);
  78. if (mealsFound.length === 0) {
  79. setEmptyListFound(true);
  80. }
  81. }
  82. const sortMealsIntoCategories = () => {
  83. mealsByCategory.clear();
  84. const mealsWithoutCategory = [];
  85. const categoriesInitiallyExpanded = true;
  86. filteredMeals.sort(function (a, b) {
  87. if (!b.category) return 1;
  88. if (!a.category) return -1;
  89. return a.category > b.category ? 1 : -1;
  90. });
  91. filteredMeals.forEach(meal => {
  92. if (meal.category) {
  93. const key = meal.category;
  94. let mappedMeals = mealsByCategory.get(key);
  95. if (!mappedMeals) {
  96. mealsByCategory.set(key, [meal]);
  97. updateIsCategoryOpen(key, categoriesInitiallyExpanded);
  98. } else {
  99. mappedMeals.push(meal);
  100. }
  101. } else {
  102. mealsWithoutCategory.push(meal);
  103. }
  104. });
  105. if (mealsWithoutCategory.length > 0) {
  106. const key = t('Meals without category');
  107. mealsByCategory.set(key, mealsWithoutCategory);
  108. updateIsCategoryOpen(key, categoriesInitiallyExpanded);
  109. }
  110. // I don't remember why this was necessary but now I don't dare to remove it
  111. forceUpdate();
  112. }
  113. const updateIsCategoryOpen = (key, value) => {
  114. setIsCategoryOpen(prevState => {return { ...prevState, [key]: value }});
  115. }
  116. const fetchAndUpdateMeals = () => {
  117. fetchAndUpdateMealsFromUser(userId, updateMealsCallback);
  118. fireIconReload();
  119. }
  120. useEffect(() => {
  121. fetchAndUpdateMeals();
  122. // eslint-disable-next-line
  123. }, [userId, pathname]);
  124. useEffect(() => {
  125. sortMealsIntoCategories();
  126. // eslint-disable-next-line
  127. }, [filteredMeals]);
  128. useEffect(() => {
  129. if (filterTags.length > 0) {
  130. const newFilteredMeals = meals.filter(meal => {
  131. if (!meal.tags || meal.tags.length === 0) return false;
  132. return filterTags.every(tag => meal.tags.includes(tag));
  133. });
  134. setFilteredMeals(newFilteredMeals);
  135. } else {
  136. setFilteredMeals(meals);
  137. }
  138. // eslint-disable-next-line
  139. }, [filterTags]
  140. );
  141. const openMealDetailView = (meal) => {
  142. if(own) navigate('detail/' + meal._id, {state: {meal}});
  143. else navigate('/meals/detail/' + meal._id, {state: {meal}});
  144. };
  145. const getListItems = () => {
  146. const listItems = [];
  147. mealsByCategory.forEach((meals, categoryName) => {
  148. const listItemsForCategory = [];
  149. meals.forEach(meal => {
  150. listItemsForCategory.push(
  151. <ListItem key={meal._id} className={classes.nestedListItem} button onClick={() => {openMealDetailView(meal); }}>
  152. <ListItemAvatar>
  153. <MealAvatar meal={meal} />
  154. </ListItemAvatar>
  155. <ListItemText primary={meal.title} />
  156. </ListItem>,
  157. <Divider key={'Divider' + meal._id} />
  158. );
  159. });
  160. const open = isCategoryOpen[categoryName];
  161. const icon = categoryIcons[categoryName];
  162. listItems.push(
  163. <ListItem button key={categoryName} onClick={() => {
  164. updateIsCategoryOpen(categoryName, !isCategoryOpen[categoryName]);
  165. }} className={classes.category} >
  166. {icon && <ListItemIcon className={classes.listItemIcon}>
  167. <FontAwesomeIcon icon={icon} />
  168. </ListItemIcon>}
  169. <ListItemText primary={categoryName} />
  170. {open ? <ExpandLess /> : <ExpandMore />}
  171. </ListItem>,
  172. <Collapse key={categoryName + 'MealList'} in={open} timeout="auto" unmountOnExit>
  173. <List component="div" disablePadding>
  174. {listItemsForCategory}
  175. </List>
  176. </Collapse>);
  177. });
  178. return listItems;
  179. }
  180. const updateFilterTags = (newTags) => {
  181. setFilterTags(newTags);
  182. }
  183. const toggleAllCategories = () => {
  184. let allClosed = Object.values(isCategoryOpen).every(isOpen => !isOpen);
  185. Object.keys(isCategoryOpen).forEach(category => {
  186. updateIsCategoryOpen(category, allClosed);
  187. });
  188. }
  189. useEffect(() => {
  190. setAllCategoriesClosed(Object.values(isCategoryOpen).every(isOpen => !isOpen));
  191. }, [isCategoryOpen]);
  192. let infoText = t("Looks like there are no meals here yet.");
  193. if(own) infoText += ' ' + t('Add one by clicking in the top right corner.');
  194. return (
  195. <>
  196. {meals.length === 0 ?
  197. <Typography className={classes.infoText}>{emptyListFound ? infoText : t('Loading') + '...'} </Typography> :
  198. <>
  199. <Box className={classes.controlBox} style={{ display: 'flex', justifyContent: own ? 'space-between' : 'end' }}>
  200. <Button variant="text"
  201. className={classes.optionRowButton}
  202. color="secondary"
  203. onClick={() => {setIsFilterOpen(prevState => !prevState)}}
  204. endIcon={isFilterOpen ? <ExpandLess /> : <ExpandMore />}>
  205. {t('Filter')}
  206. </Button>
  207. <Button variant="text"
  208. className={classes.optionRowButton}
  209. style={{ marginLeft: 'auto' }}
  210. color="primary"
  211. endIcon={allCategoriesClosed ? <UnfoldMore /> : <UnfoldLess />}
  212. onClick={toggleAllCategories}>
  213. {allCategoriesClosed ? t('expand all') : t('collapse all')}
  214. </Button>
  215. </Box>
  216. {isFilterOpen && <Box className={classes.filterBox}>
  217. <SelectMealTags currentTags={filterTags}
  218. own={own}
  219. otherUserId={userId}
  220. updateTags={updateFilterTags}
  221. placeholderText={t('Filter by Tags')}
  222. className={classes.filterTags}
  223. customControlStyles={{ borderRadius: 0 }} />
  224. </Box>}
  225. {filteredMeals.length === 0 ?
  226. <Typography className={classes.infoText}>{t('No meals found for filter selection')}</Typography> :
  227. <List component="nav" className={classes.root} aria-label="meal list" disablePadding>
  228. {getListItems()}
  229. </List>}
  230. </>
  231. }
  232. </>
  233. );
  234. }
  235. Meals.propTypes = {
  236. /** userId of user whose meals are to be displayed */
  237. userId: string.isRequired,
  238. /** are these the user's own meals or is another user watching foreign meals? In the latter case editing will be prohibited. */
  239. own: bool.isRequired,
  240. }
  241. export default Meals;