|
@@ -1,15 +1,17 @@
|
|
|
-import React, { useEffect, useState } from 'react';
|
|
|
+import React, { useEffect, useMemo, useState } from 'react';
|
|
|
import { makeStyles } from "@material-ui/styles";
|
|
|
import { alpha, Box, Button, Collapse, Divider, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText, Typography } from "@material-ui/core";
|
|
|
import { ExpandLess, ExpandMore, UnfoldLess, UnfoldMore } from "@material-ui/icons";
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
import { fetchAndUpdateMealsFromUser } from "./meals.util";
|
|
|
import useCategoryIcons from "./useCategoryIcons";
|
|
|
-import { bool, string } from "prop-types";
|
|
|
+import { bool, func, instanceOf, string } from "prop-types";
|
|
|
import MealAvatar from "./MealAvatar";
|
|
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
|
import SelectMealTags from "./SelectMealTags";
|
|
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
|
+import { useTranslationsMap } from "../util/translation/useTranslationMap";
|
|
|
+import { LoadingBody } from "../Loading";
|
|
|
|
|
|
const useStyles = makeStyles(theme => ({
|
|
|
infoText: {
|
|
@@ -53,73 +55,50 @@ const useStyles = makeStyles(theme => ({
|
|
|
|
|
|
/** Content of page that displays all meals of a given use and opens their detail views on click.
|
|
|
* If meals belong to logged in user, editing will be allowed.
|
|
|
- * todo: keep filters if meal is visualized
|
|
|
- * todo: add search in addition to filters
|
|
|
- * todo: restructure to have logic in a wrapper component, so that refresh can be triggered
|
|
|
- * todo: Snackbar visualization */
|
|
|
+ * todo: add search in addition to filters */
|
|
|
const Meals = (props) => {
|
|
|
const classes = useStyles(props);
|
|
|
+ const { own, userId, addThingsToTranslate, thingsToTranslate, isTranslated } = props;
|
|
|
+
|
|
|
const { t } = useTranslation();
|
|
|
let navigate = useNavigate();
|
|
|
+ const { print } = useTranslationsMap(isTranslated);
|
|
|
|
|
|
const { pathname, state } = useLocation();
|
|
|
- const { own, userId } = props;
|
|
|
|
|
|
- const [isFilterOpen, setIsFilterOpen] = useState(false);
|
|
|
- const [filterTags, setFilterTags] = useState([]);
|
|
|
- const [meals, setMeals] = useState([]);
|
|
|
- const [filteredMeals, setFilteredMeals] = useState([]);
|
|
|
- const [mealsByCategory,] = useState(new Map());
|
|
|
+ const [isFilterOpen, setIsFilterOpen] = useState(state?.activeMealFilter?.isFilterOpen ?? false);
|
|
|
+ const [filterTags, setFilterTags] = useState(state?.activeMealFilter?.filterTags ?? []);
|
|
|
+ const [meals, setMeals] = useState(state?.activeMealFilter?.filteredMeals ?? []); // take state-cached meals while loading takes place in background -> no apparent loading for user
|
|
|
const [isCategoryOpen, setIsCategoryOpen] = useState({});
|
|
|
- const [allCategoriesClosed, setAllCategoriesClosed] = useState(false);
|
|
|
const [categoryIcons, fireIconReload] = useCategoryIcons(userId);
|
|
|
-
|
|
|
const [emptyListFound, setEmptyListFound] = useState(false);
|
|
|
|
|
|
- const updateMealsCallback = (mealsFound) => {
|
|
|
- setMeals(mealsFound);
|
|
|
- if (mealsFound.length === 0) {
|
|
|
- setEmptyListFound(true);
|
|
|
- }
|
|
|
- }
|
|
|
+ // get all categories that are in use (uniquely, without null values)
|
|
|
+ const usedCategories = useMemo(() => Array.from(new Set(meals.map(meal => meal.category).filter(cat => cat))), [meals]);
|
|
|
|
|
|
- const sortMealsIntoCategories = () => {
|
|
|
- mealsByCategory.clear();
|
|
|
- const mealsWithoutCategory = [];
|
|
|
- const categoriesInitiallyExpanded = true;
|
|
|
- filteredMeals.sort(function (a, b) {
|
|
|
- if (!b.category) return 1;
|
|
|
- if (!a.category) return -1;
|
|
|
- return a.category > b.category ? 1 : -1;
|
|
|
- });
|
|
|
- filteredMeals.forEach(meal => {
|
|
|
- if (meal.category) {
|
|
|
- const key = meal.category;
|
|
|
- let mappedMeals = mealsByCategory.get(key);
|
|
|
- if (!mappedMeals) {
|
|
|
- mealsByCategory.set(key, [meal]);
|
|
|
- updateIsCategoryOpen(key, categoriesInitiallyExpanded);
|
|
|
- } else {
|
|
|
- mappedMeals.push(meal);
|
|
|
- }
|
|
|
- } else {
|
|
|
- mealsWithoutCategory.push(meal);
|
|
|
- }
|
|
|
- });
|
|
|
- if (mealsWithoutCategory.length > 0) {
|
|
|
- const key = t('Meals without category');
|
|
|
- mealsByCategory.set(key, mealsWithoutCategory);
|
|
|
- updateIsCategoryOpen(key, categoriesInitiallyExpanded);
|
|
|
+ // add elements to translate if this has not been done already
|
|
|
+ // it is in useEffect because it updates the state of the parent component which cannot be done while rendering
|
|
|
+ useEffect(() => {
|
|
|
+ if (addThingsToTranslate && thingsToTranslate && meals.length > 0) {
|
|
|
+ console.log('checking what to add');
|
|
|
+ const titles = meals.map(meal => meal.title);
|
|
|
+ const thingsToAdd = [...titles, ...usedCategories];
|
|
|
+ const filtered = thingsToAdd.filter(str => !thingsToTranslate.has(str));
|
|
|
+ if (filtered.length > 0) addThingsToTranslate(filtered);
|
|
|
}
|
|
|
- //forceUpdate(); // I don't remember why this was necessary but now I don't dare to remove it
|
|
|
- }
|
|
|
+ }, [addThingsToTranslate, thingsToTranslate, meals, usedCategories]);
|
|
|
|
|
|
const updateIsCategoryOpen = (key, value) => {
|
|
|
setIsCategoryOpen(prevState => {return { ...prevState, [key]: value }});
|
|
|
}
|
|
|
|
|
|
const fetchAndUpdateMeals = () => {
|
|
|
- fetchAndUpdateMealsFromUser(userId, updateMealsCallback);
|
|
|
+ fetchAndUpdateMealsFromUser(userId, (mealsFound) => {
|
|
|
+ setMeals(mealsFound);
|
|
|
+ if (mealsFound.length === 0) {
|
|
|
+ setEmptyListFound(true);
|
|
|
+ }
|
|
|
+ });
|
|
|
fireIconReload();
|
|
|
}
|
|
|
|
|
@@ -128,39 +107,64 @@ const Meals = (props) => {
|
|
|
// eslint-disable-next-line
|
|
|
}, [userId, pathname]);
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- if (state) {
|
|
|
- if (state.refresh) { // force refresh of meals
|
|
|
- fetchAndUpdateMeals();
|
|
|
- }
|
|
|
- if (state.activeMealFilter) { // restore last filter selection
|
|
|
- const { filteredMeals, filterTags, isFilterOpen } = state.activeMealFilter;
|
|
|
- setFilteredMeals(filteredMeals);
|
|
|
- setFilterTags(filterTags);
|
|
|
- setIsFilterOpen(isFilterOpen);
|
|
|
- }
|
|
|
+ if (state?.refresh === true) { // force reload meals after undoing deletion and then remove refresh from state
|
|
|
+ fetchAndUpdateMeals();
|
|
|
+ delete state.refresh;
|
|
|
+ navigate(window.location, { replace: true, state: { ...state } });
|
|
|
+ }
|
|
|
+
|
|
|
+ // filter meals according to set filters
|
|
|
+ let filteredMeals = useMemo(() => {
|
|
|
+ if (filterTags.length > 0) {
|
|
|
+ return meals.filter(meal => {
|
|
|
+ if (!meal.tags || meal.tags.length === 0) return false; // do not include meals without tags -> they cannot correspond with any set filter
|
|
|
+ return filterTags.every(tag => meal.tags.includes(tag));
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ return meals;
|
|
|
}
|
|
|
- // eslint-disable-next-line
|
|
|
- }, [state]);
|
|
|
+ }, [meals, filterTags]);
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- sortMealsIntoCategories();
|
|
|
- // eslint-disable-next-line
|
|
|
- }, [filteredMeals]);
|
|
|
+ const sortedCategories = useMemo(() => {
|
|
|
+ // sort categories alphabetically
|
|
|
+ const sorted = [...usedCategories.sort(function (a, b) { return a > b ? 1 : -1; })]; // deep copy to avoid adding meals without category to original usedCategories
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- if (filterTags.length > 0) {
|
|
|
- const newFilteredMeals = meals.filter(meal => {
|
|
|
- if (!meal.tags || meal.tags.length === 0) return false;
|
|
|
- return filterTags.every(tag => meal.tags.includes(tag));
|
|
|
- });
|
|
|
- setFilteredMeals(newFilteredMeals);
|
|
|
- } else {
|
|
|
- setFilteredMeals(meals);
|
|
|
- }
|
|
|
- // eslint-disable-next-line
|
|
|
- }, [filterTags, meals]
|
|
|
- );
|
|
|
+ // add a category for meals without category, if there are any
|
|
|
+ const mealsWithoutCategory = filteredMeals.filter(meal => !meal.category);
|
|
|
+ if (mealsWithoutCategory.length > 0) {
|
|
|
+ sorted.push(t('Meals without category'));
|
|
|
+ }
|
|
|
+
|
|
|
+ return sorted;
|
|
|
+ }, [filteredMeals, usedCategories, t]);
|
|
|
+
|
|
|
+ // sort meals into categories
|
|
|
+ const mealsByCategory = useMemo(() => {
|
|
|
+ const mealsByCat = new Map();
|
|
|
+
|
|
|
+ // for each category in use, get all corresponding meals and add them to the map
|
|
|
+ sortedCategories.forEach(category => {
|
|
|
+ const mealsInCategory = filteredMeals.filter(meal => {
|
|
|
+ if (!meal.category) {
|
|
|
+ return category === t('Meals without category');
|
|
|
+ }
|
|
|
+ return meal.category === category;
|
|
|
+ });
|
|
|
+ mealsByCat.set(category, mealsInCategory);
|
|
|
+ });
|
|
|
+ return mealsByCat;
|
|
|
+ }, [filteredMeals, sortedCategories, t]);
|
|
|
+
|
|
|
+ // set all categories to be initially open
|
|
|
+ if (meals.length > 0 && Object.keys(isCategoryOpen).length === 0) { // only do this after meals have been loaded, otherwise it results in an infinite loop
|
|
|
+ const categoriesInitiallyExpanded = true;
|
|
|
+
|
|
|
+ const categoriesOpenObject = sortedCategories.reduce((accumulator, catName) => {
|
|
|
+ return { ...accumulator, [catName]: categoriesInitiallyExpanded };
|
|
|
+ }, {});
|
|
|
+
|
|
|
+ setIsCategoryOpen(categoriesOpenObject);
|
|
|
+ }
|
|
|
|
|
|
const openMealDetailView = (meal) => {
|
|
|
const activeMealFilter = {
|
|
@@ -169,7 +173,6 @@ const Meals = (props) => {
|
|
|
isFilterOpen,
|
|
|
};
|
|
|
// set the active meal filter in the state of the current location before navigating away, because it will be restored when navigating back from the detail view with navigate(-1)
|
|
|
- console.log('activeMealFilter', activeMealFilter);
|
|
|
navigate(window.location, { replace: true, state: { activeMealFilter } });
|
|
|
if (own) {
|
|
|
navigate('detail/' + meal._id, { state: { meal } });
|
|
@@ -178,42 +181,6 @@ const Meals = (props) => {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const getListItems = () => {
|
|
|
- const listItems = [];
|
|
|
- mealsByCategory.forEach((meals, categoryName) => {
|
|
|
- const listItemsForCategory = [];
|
|
|
- meals.forEach(meal => {
|
|
|
- listItemsForCategory.push(
|
|
|
- <ListItem key={meal._id} className={classes.nestedListItem} button onClick={() => {openMealDetailView(meal); }}>
|
|
|
- <ListItemAvatar>
|
|
|
- <MealAvatar meal={meal} />
|
|
|
- </ListItemAvatar>
|
|
|
- <ListItemText primary={meal.title} />
|
|
|
- </ListItem>,
|
|
|
- <Divider key={'Divider' + meal._id} />
|
|
|
- );
|
|
|
- });
|
|
|
- const open = isCategoryOpen[categoryName];
|
|
|
- const icon = categoryIcons[categoryName];
|
|
|
- listItems.push(
|
|
|
- <ListItem button key={categoryName} onClick={() => {
|
|
|
- updateIsCategoryOpen(categoryName, !isCategoryOpen[categoryName]);
|
|
|
- }} className={classes.category}>
|
|
|
- {icon && <ListItemIcon className={classes.listItemIcon}>
|
|
|
- <FontAwesomeIcon icon={icon} />
|
|
|
- </ListItemIcon>}
|
|
|
- <ListItemText primary={categoryName} />
|
|
|
- {open ? <ExpandLess /> : <ExpandMore />}
|
|
|
- </ListItem>,
|
|
|
- <Collapse key={categoryName + 'MealList'} in={open} timeout="auto" unmountOnExit>
|
|
|
- <List component="div" disablePadding>
|
|
|
- {listItemsForCategory}
|
|
|
- </List>
|
|
|
- </Collapse>);
|
|
|
- });
|
|
|
- return listItems;
|
|
|
- }
|
|
|
-
|
|
|
const updateFilterTags = (newTags) => {
|
|
|
setFilterTags(newTags);
|
|
|
}
|
|
@@ -225,53 +192,94 @@ const Meals = (props) => {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- setAllCategoriesClosed(Object.values(isCategoryOpen).every(isOpen => !isOpen));
|
|
|
- }, [isCategoryOpen]);
|
|
|
+ const areAllCategoriesClosed = useMemo(() => Object.values(isCategoryOpen).every(isOpen => isOpen === false), [isCategoryOpen]);
|
|
|
+
|
|
|
+ const getListItems = () => {
|
|
|
+ const listItems = [];
|
|
|
+ mealsByCategory.forEach((meals, categoryName) => {
|
|
|
+ if (meals.length > 0) { // only show categories with meals inside (when filtering otherwise empty categories will be displayed
|
|
|
+ const listItemsForCategory = [];
|
|
|
+ meals.forEach(meal => {
|
|
|
+ listItemsForCategory.push(
|
|
|
+ <ListItem key={meal._id} className={classes.nestedListItem} button onClick={() => {openMealDetailView(meal); }}>
|
|
|
+ <ListItemAvatar>
|
|
|
+ <MealAvatar meal={meal} />
|
|
|
+ </ListItemAvatar>
|
|
|
+ <ListItemText primary={print(meal.title)} />
|
|
|
+ </ListItem>,
|
|
|
+ <Divider key={'Divider' + meal._id} />
|
|
|
+ );
|
|
|
+ });
|
|
|
+ const open = isCategoryOpen[categoryName];
|
|
|
+ const icon = categoryIcons[categoryName];
|
|
|
+ listItems.push(
|
|
|
+ <ListItem button key={categoryName} onClick={() => {
|
|
|
+ updateIsCategoryOpen(categoryName, !isCategoryOpen[categoryName]);
|
|
|
+ }} className={classes.category}>
|
|
|
+ {icon && <ListItemIcon className={classes.listItemIcon}>
|
|
|
+ <FontAwesomeIcon icon={icon} />
|
|
|
+ </ListItemIcon>}
|
|
|
+ <ListItemText>{print(categoryName)}</ListItemText>
|
|
|
+ {open ? <ExpandLess /> : <ExpandMore />}
|
|
|
+ </ListItem>,
|
|
|
+ <Collapse key={categoryName + 'MealList'} in={open} timeout="auto" unmountOnExit>
|
|
|
+ <List component="div" disablePadding>
|
|
|
+ {listItemsForCategory}
|
|
|
+ </List>
|
|
|
+ </Collapse>);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return listItems;
|
|
|
+ }
|
|
|
|
|
|
let infoText = t("Looks like there are no meals here yet.");
|
|
|
if (own) infoText += ' ' + t('Add one by clicking in the top right corner.');
|
|
|
|
|
|
- return (
|
|
|
- <>
|
|
|
- {meals.length === 0 ?
|
|
|
- <Typography className={classes.infoText}>{emptyListFound ? infoText : t('Loading') + '...'} </Typography> :
|
|
|
- <>
|
|
|
- <Box className={classes.controlBox} style={{ display: 'flex', justifyContent: own ? 'space-between' : 'end' }}>
|
|
|
- <Button variant="text"
|
|
|
- className={classes.optionRowButton}
|
|
|
- color="secondary"
|
|
|
- onClick={() => {setIsFilterOpen(prevState => !prevState)}}
|
|
|
- endIcon={isFilterOpen ? <ExpandLess /> : <ExpandMore />}>
|
|
|
- {t('Filter')}
|
|
|
- </Button>
|
|
|
- <Button variant="text"
|
|
|
- className={classes.optionRowButton}
|
|
|
- style={{ marginLeft: 'auto' }}
|
|
|
- color="primary"
|
|
|
- endIcon={allCategoriesClosed ? <UnfoldMore /> : <UnfoldLess />}
|
|
|
- onClick={toggleAllCategories}>
|
|
|
- {allCategoriesClosed ? t('expand all') : t('collapse all')}
|
|
|
- </Button>
|
|
|
- </Box>
|
|
|
- {isFilterOpen && <Box className={classes.filterBox}>
|
|
|
- <SelectMealTags currentTags={filterTags}
|
|
|
- own={own}
|
|
|
- otherUserId={userId}
|
|
|
- updateTags={updateFilterTags}
|
|
|
- placeholderText={t('Filter by Tags')}
|
|
|
- className={classes.filterTags}
|
|
|
- customControlStyles={{ borderRadius: 0 }} />
|
|
|
- </Box>}
|
|
|
- {filteredMeals.length === 0 ?
|
|
|
- <Typography className={classes.infoText}>{t('No meals found for filter selection')}</Typography> :
|
|
|
- <List component="nav" className={classes.root} aria-label="meal list" disablePadding>
|
|
|
- {getListItems()}
|
|
|
- </List>}
|
|
|
- </>
|
|
|
- }
|
|
|
- </>
|
|
|
- );
|
|
|
+ if (meals.length === 0) {
|
|
|
+ return (
|
|
|
+ emptyListFound ? <Typography className={classes.infoText}>{infoText}</Typography> : <LoadingBody />
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <Box className={classes.controlBox} style={{ display: 'flex', justifyContent: own ? 'space-between' : 'end' }}>
|
|
|
+ <Button variant="text"
|
|
|
+ className={classes.optionRowButton}
|
|
|
+ color="secondary"
|
|
|
+ onClick={() => {setIsFilterOpen(prevState => !prevState)}}
|
|
|
+ endIcon={isFilterOpen ? <ExpandLess /> : <ExpandMore />}>
|
|
|
+ {t('Filter')}
|
|
|
+ </Button>
|
|
|
+ <Button variant="text"
|
|
|
+ className={classes.optionRowButton}
|
|
|
+ style={{ marginLeft: 'auto' }}
|
|
|
+ color="primary"
|
|
|
+ endIcon={areAllCategoriesClosed ? <UnfoldMore /> : <UnfoldLess />}
|
|
|
+ onClick={toggleAllCategories}>
|
|
|
+ {areAllCategoriesClosed ? t('expand all') : t('collapse all')}
|
|
|
+ </Button>
|
|
|
+ </Box>
|
|
|
+ {isFilterOpen && <Box className={classes.filterBox}>
|
|
|
+ <SelectMealTags currentTags={filterTags}
|
|
|
+ own={own}
|
|
|
+ otherUserId={userId}
|
|
|
+ updateTags={updateFilterTags}
|
|
|
+ placeholderText={t('Filter by Tags')}
|
|
|
+ className={classes.filterTags}
|
|
|
+ customControlStyles={{ borderRadius: 0 }}
|
|
|
+ shouldBeTranslated={isTranslated} />
|
|
|
+ </Box>
|
|
|
+ }
|
|
|
+ {filteredMeals.length === 0
|
|
|
+ ?
|
|
|
+ <Typography className={classes.infoText}>{t('No meals found for filter selection')}</Typography>
|
|
|
+ :
|
|
|
+ <List component="nav" className={classes.root} aria-label="meal list" disablePadding>
|
|
|
+ {getListItems()}
|
|
|
+ </List>}
|
|
|
+ </>
|
|
|
+ );
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
Meals.propTypes = {
|
|
@@ -279,6 +287,18 @@ Meals.propTypes = {
|
|
|
userId: string.isRequired,
|
|
|
/** are these the user's own meals or is another user watching foreign meals? In the latter case editing will be prohibited. */
|
|
|
own: bool.isRequired,
|
|
|
+ /** function provided by ContactsContent to determine the strings to translate */
|
|
|
+ addThingsToTranslate: func,
|
|
|
+ /** Set provided by ContactsContent to avoid adding things more than necessary */
|
|
|
+ thingsToTranslate: instanceOf(Set),
|
|
|
+ /** whether to use translated values or not */
|
|
|
+ isTranslated: bool,
|
|
|
+}
|
|
|
+
|
|
|
+Meals.defaultProps = {
|
|
|
+ addThingsToTranslate: null,
|
|
|
+ thingsToTranslate: null,
|
|
|
+ isTranslated: false,
|
|
|
}
|
|
|
|
|
|
export default Meals;
|