Commit 84ffef76 authored by Florent Chehab's avatar Florent Chehab

Enhance/fix(frontend): Error boundaries, routing, HOC

* Added error boundaries on each page to prevent full crash of the app (through HOC).
* Improved routing in the app, tabs on the university page are now identified.
* Moved the University info consumer to cleaner HOC.
* Fixed bug in CRUD actions error handling.
* Updated doc about jetbrain "safe write"
* Fixed package.json general declaration

Fixes #111 #101 #114 #115
parent b9500eb2
Pipeline #38625 passed with stages
in 3 minutes and 21 seconds
......@@ -10,6 +10,8 @@ In this short documentation, some configurations "issues" regarding your choice
!> In JetBrain IDEs you should also deactivate *auto-save* since this would cause useless recompilation of the frontend (or useless restart of the backend). Check [this link](https://intellij-support.jetbrains.com/hc/en-us/community/posts/207054215-Disabling-autosave).
!> In JetBrain IDEs **You must disable `Use "safe write"...` to make sure changes are detected and the project automatically recompiles.**
## For the backend
......
......@@ -17,7 +17,8 @@
"url": "git@gitlab.utc.fr:rex-dri/rex-dri.git"
},
"author": "",
"license": "ISC",
"license": "BSD-2-Clause",
"private": true,
"dependencies": {
"@date-io/date-fns": "^1.1.0",
"@date-io/luxon": "^1.1.0",
......
......@@ -16,16 +16,14 @@ import Chip from "@material-ui/core/Chip";
import Avatar from "@material-ui/core/Avatar";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import SchoolIcon from "@material-ui/icons/School";
import { mainListItems, secondaryListItems, thirdListItems } from "./listItems";
import {mainListItems, secondaryListItems, thirdListItems} from "./listItems";
import FullScreenDialog from "./FullScreenDialog";
import { connect } from "react-redux";
import {connect} from "react-redux";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import {
Route,
Redirect
} from "react-router-dom";
import {compose} from "recompose";
import {withErrorBoundary} from "../common/ErrorBoundary";
import {Redirect, Route} from "react-router-dom";
import getActions from "../../redux/api/getActions";
......@@ -37,6 +35,7 @@ import PageSettings from "../pages/PageSettings";
import PageUser from "../pages/PageUser";
import PageLists from "../pages/PageLists";
const DRAWER_WIDTH = 240;
/**
......@@ -125,10 +124,10 @@ class App extends CustomComponentForAPI {
<Route
exact
path="/app/university/"
render={() => (<Redirect to="/app/university/undefined" />)}
render={() => (<Redirect to="/app/university/undefined/" />)}
/>
<Route path="/app/university/:id" component={PageUniversity} />
<Route path="/app/user/:id" component={PageUser} />
<Route path="/app/university/:univId/:tabName?" component={PageUniversity} />
<Route path="/app/user/:userId" component={PageUser} />
</div>
</main>
......@@ -229,4 +228,8 @@ const styles = theme => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles, { withTheme: true })(App));
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withStyles(styles, { withTheme: true }),
withErrorBoundary(),
)(App);
......@@ -44,7 +44,7 @@ export const mainListItems = (
</ListItem>
</NavLink>
<NavLink to={"/app/university/undefined"} style={{ textDecoration: "none" }}>
<NavLink to={"/app/university/undefined/"} style={{ textDecoration: "none" }}>
<ListItem button>
<ListItemIcon>
<LocationCityIcon />
......
......@@ -274,7 +274,7 @@ class CustomComponentForAPI extends Component {
* read, create and update are taken into account
*
* @param {string} propName
* @returns {Any}
* @returns {number}
* @memberof CustomComponentForAPI
*/
getLatestReadTime(propName) {
......
import React from "react";
import Alert from "./Alert";
import PropTypes from "prop-types";
import {setDisplayName} from "recompose";
function clear() {
return {error: null, errorInfo: null, alertOpen: true};
}
/**
* Component to act as an error boundary, to prevent the app from deep unrecoverable crashes.
*/
class ErrorBoundary extends React.Component {
state = clear();
componentDidCatch(error, errorInfo) {
// Catch errors in any components below and re-render with error message
this.setState({
error,
errorInfo,
alertOpen: true,
});
// You can also log error messages to an error reporting service here
}
render() {
const {error, errorInfo} = this.state;
// In case of error
if (errorInfo) {
// eslint-disable-next-line no-console
console.log(error, errorInfo);
return (
<Alert open={this.state.alertOpen}
info={true}
title={"Une erreur inconnue c'est produite dans l'application. Nous vous prions de nous en excuser."}
description={"Nous vous invitons à recharger la page. Si l'erreur persiste, merci de contacter les administrateurs du site."}
infoText={"C'est noté, je sais que vous faîtes de votre mieux :)"}
handleResponse={() => {}}
handleClose={() => this.setState(clear())}
/>
);
}
// Normally, just render children
return this.props.children;
}
}
ErrorBoundary.propTypes = {
children: PropTypes.node.isRequired,
};
/**
* HOC (higher order component) wrapper to provide an error boundary to the sub components.
*
* @returns {function(*): function(*): *}
*/
export function withErrorBoundary() {
return Component => setDisplayName("Error boundary")(props => (
<ErrorBoundary>
<Component {...props}/>
</ErrorBoundary>
));
}
/**
* React context to hold visibility information for optimization purposes.
*/
import React from "react";
const VisibilityContext = React.createContext();
export default VisibilityContext;
......@@ -292,7 +292,7 @@ class Editor extends Component {
open: true,
info: true,
title: "L'enregistrement sur le serveur a échoué.",
description: error + "\n \nVous pourrez réessayer après avoir fermer cette alerte. Si l'erreur persiste, vérifier votre connexion internet ou contacter les administrateurs du site.",
description: "Vous pourrez réessayer après avoir fermer cette alerte. Si l'erreur persiste, vérifier votre connexion internet ou contacter les administrateurs du site.\n\n" + error,
infoText: "J'ai compris",
handleResponse: () => {
this.props.clearSaveError();
......
......@@ -10,7 +10,7 @@ import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import IconAdd from "@material-ui/icons/Add";
import IconClose from "@material-ui/icons/Close";
import Link from "react-router-dom/Link";
import { Link } from "react-router-dom";
import MyCardMedia from "./MyCardMedia";
import { withLeaflet } from "react-leaflet";
......@@ -50,7 +50,7 @@ class UnivPopupContent extends Component {
</CardContent>
</CardActionArea>
<CardActions>
<Link to={"/app/university/" + univId} className={classes.moreInfoLink}>
<Link to={`/app/university/${univId}/`} className={classes.moreInfoLink}>
<Button variant="contained" size="small" color="primary">
< IconAdd />
En savoir plus
......
......@@ -4,6 +4,8 @@ import withStyles from "@material-ui/core/styles/withStyles";
import Typography from "@material-ui/core/Typography";
import Paper from "@material-ui/core/Paper";
import Markdown from "../common/Markdown";
import {compose} from "recompose";
import {withErrorBoundary} from "../common/ErrorBoundary";
const source = `
......@@ -91,4 +93,7 @@ PageHome.propTypes = {
const styles = {};
export default withStyles(styles, { withTheme: true })(PageHome);
export default compose(
withStyles(styles, { withTheme: true }),
withErrorBoundary()
)(PageHome);
import React from "react";
import PropTypes from "prop-types";
import compose from "recompose/compose";
import { connect } from "react-redux";
import {connect} from "react-redux";
import withStyles from "@material-ui/core/styles/withStyles";
import Paper from "@material-ui/core/Paper";
......@@ -13,6 +13,7 @@ import Markdown from "../common/Markdown";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import getActions from "../../redux/api/getActions";
import Recommendation from "../recommendation/Recommendation";
import {withErrorBoundary} from "../common/ErrorBoundary";
const source = "Ici vous pourrez bientôt voir toutes vos listes d'universités";
......@@ -130,5 +131,6 @@ const styles = theme => ({
export default compose(
withStyles(styles, { withTheme: true }),
connect(mapStateToProps, mapDispatchToProps)
connect(mapStateToProps, mapDispatchToProps),
withErrorBoundary(),
)(PageLists);
......@@ -5,7 +5,8 @@ import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import UnivMap from "../map/UnivMap";
import Paper from "@material-ui/core/Paper";
import {compose} from "recompose";
import {withErrorBoundary} from "../common/ErrorBoundary";
/**
......@@ -39,4 +40,7 @@ PageMap.propTypes = {
const styles = {};
export default withStyles(styles, { withTheme: true })(PageMap);
export default compose(
withStyles(styles, { withTheme: true }),
withErrorBoundary()
)(PageMap);
......@@ -5,6 +5,8 @@ import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import Search from "../search/Search";
import Paper from "@material-ui/core/Paper";
import {withErrorBoundary} from "../common/ErrorBoundary";
import {compose} from "recompose";
/**
......@@ -37,4 +39,7 @@ PageSearch.propTypes = {
};
const styles = {};
export default withStyles(styles, { withTheme: true })(PageSearch);
export default compose(
withStyles(styles, { withTheme: true }),
withErrorBoundary(),
)(PageSearch);
......@@ -5,6 +5,8 @@ import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import Settings from "../settings/Settings";
import Paper from "@material-ui/core/Paper";
import {withErrorBoundary} from "../common/ErrorBoundary";
import {compose} from "recompose";
/**
......@@ -36,4 +38,7 @@ PageSettings.propTypes = {
};
const styles = {};
export default withStyles(styles, { withTheme: true })(PageSettings);
export default compose(
withStyles(styles, { withTheme: true }),
withErrorBoundary()
)(PageSettings);
......@@ -3,7 +3,7 @@ import PropTypes from "prop-types";
import Typography from "@material-ui/core/Typography";
import Paper from "@material-ui/core/Paper";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import { connect } from "react-redux";
import {connect} from "react-redux";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogTitle from "@material-ui/core/DialogTitle";
......@@ -12,17 +12,14 @@ import Button from "@material-ui/core/Button";
import UniversityTemplate from "../university/UniversityTemplate";
import UnivInfoProvider from "../university/common/UnivInfoProvider";
import {
NavLink,
Redirect
} from "react-router-dom";
import {NavLink, Redirect} from "react-router-dom";
import getActions from "../../redux/api/getActions";
import {
saveUniversityBeingViewed
} from "../../redux/actions/universityPage";
import {saveUniversityBeingViewed} from "../../redux/actions/universityPage";
import compose from "recompose/compose";
import {withStyles} from "@material-ui/core";
import {withErrorBoundary} from "../common/ErrorBoundary";
/**
......@@ -40,8 +37,8 @@ class PageUniversity extends CustomComponentForAPI {
if (this.props.universities.readSucceeded.readAt) {
// we have the university data
const universities = this.getLatestReadData("universities"),
{ match, universityBeingViewed } = this.props,
requestedUniversity = match.params.id;
{match, universityBeingViewed} = this.props,
requestedUniversity = match.params.univId;
if (requestedUniversity != "undefined"
&& universities.find(univ => univ.id == requestedUniversity)
......@@ -49,25 +46,72 @@ class PageUniversity extends CustomComponentForAPI {
this.props.saveUniversityInView(requestedUniversity);
}
}
}
renderUniversityNotFound() {
return (
<Dialog
open={true}
//onClose={this.handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{"L'université demandée n'est pas reconnue !"}</DialogTitle>
<DialogActions>
<NavLink to="/app/university" style={{textDecoration: "none"}}>
<Button variant="contained" color="primary">
C'est noté, ramenez-moi sur le droit chemin.
</Button>
</NavLink>
</DialogActions>
</Dialog>
);
}
renderUniversityUndefinedButHavePrevious(univId) {
return (
<Redirect to={`/app/university/${univId}/`}/>
);
}
renderFirstTimeHere() {
return (
<Paper className={this.props.classes.paper}>
<Typography>
C'est la première fois que vous consulter cet onglet. Nous vous invitons à <NavLink to="/app/map/">parcourir les universités</NavLink> dans un
premier temps. 😁
</Typography>
</Paper>
);
}
renderDefaultView(univId, tabName) {
return (
<UnivInfoProvider univId={univId}>
<UniversityTemplate tabName={tabName} univId={univId}/>
</UnivInfoProvider>
);
}
customRender() {
const { match, universityBeingViewed } = this.props,
requestedUnivId = match.params.id,
const {match, universityBeingViewed} = this.props,
requestedUnivId = match.params.univId,
tabName = match.params.tabName,
universities = this.getLatestReadData("universities");
if (requestedUnivId == "undefined") {
if (requestedUnivId === "undefined") {
if (universityBeingViewed != null) {
return renderUniversityUndefinedButHavePrevious(universityBeingViewed);
return this.renderUniversityUndefinedButHavePrevious(universityBeingViewed);
} else {
return renderFirstTimeHere();
return this.renderFirstTimeHere();
}
} else {
if (universities.find(univ => univ.id == requestedUnivId)) {
return renderDefaultView(requestedUnivId);
if (universities.find(univ => parseInt(univ.id) === parseInt(requestedUnivId))) {
return this.renderDefaultView(requestedUnivId, tabName);
} else {
return renderUniversityNotFound();
return this.renderUniversityNotFound();
}
}
}
......@@ -76,58 +120,11 @@ class PageUniversity extends CustomComponentForAPI {
PageUniversity.propTypes = {
universityBeingViewed: PropTypes.string,
universities: PropTypes.object.isRequired,
match: PropTypes.object,
match: PropTypes.object.isRequired,
saveUniversityInView: PropTypes.func.isRequired,
classes: PropTypes.object.isRequired,
};
function renderUniversityNotFound() {
return (
<Dialog
open={true}
//onClose={this.handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{"L'université demandée n'est pas reconnue !"}</DialogTitle>
<DialogActions>
<NavLink to="/app/university" style={{ textDecoration: "none" }}>
<Button variant="contained" color="primary">
C'est noté, ramenez-moi sur le droit chemin.
</Button>
</NavLink>
</DialogActions>
</Dialog>
);
}
function renderUniversityUndefinedButHavePrevious(univId) {
return (
<Redirect to={"/app/university/" + univId} />
);
}
function renderFirstTimeHere() {
return (
<Paper>
<Typography >
C'est la première fois que vous utilisez cet onglet et vous n'avez pas encore util....
</Typography>
</Paper>
);
}
function renderDefaultView(univId) {
return (
<UnivInfoProvider univId={univId}>
<UniversityTemplate />
</UnivInfoProvider>
);
}
const mapStateToProps = (state) => {
return {
universities: state.api.universitiesAll,
......@@ -144,6 +141,14 @@ const mapDispatchToProps = (dispatch) => {
};
};
export default connect(mapStateToProps, mapDispatchToProps, null, {
pure: false
})(PageUniversity);
const styles = theme => ({
paper: theme.myPaper
});
export default compose(
withStyles(styles, {withTheme: true}),
connect(mapStateToProps, mapDispatchToProps, null, {
pure: false
}),
withErrorBoundary()
)(PageUniversity);
......@@ -10,6 +10,7 @@ import Grid from "@material-ui/core/Grid";
import Divider from "@material-ui/core/Divider";
import UserInfo from "../user/UserInfo";
import {compose} from "recompose";
import {withErrorBoundary} from "../common/ErrorBoundary";
/**
......@@ -20,7 +21,7 @@ import {compose} from "recompose";
*/
class PageUser extends React.Component {
getUserIdFromUrl() {
return this.props.match.params.id;
return this.props.match.params.userId;
}
render() {
......@@ -76,7 +77,7 @@ PageUser.propTypes = {
theme: PropTypes.object.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
id: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
})
}).isRequired,
classes: PropTypes.object.isRequired,
......@@ -101,4 +102,5 @@ const styles = theme => ({
export default compose(
withStyles(styles, {withTheme: true}),
withErrorBoundary(),
)(PageUser);
......@@ -17,7 +17,7 @@ class UnivBlock extends React.Component {
render() {
let { univ } = this.props,
comment = univ.comment,
link = `/app/university/${univ.id}`,
link = `/app/university/${univ.id}/`,
source = `# [${univ.name}](${link}) \n ${univ.city}, ${univ.country}`;
return (
<Paper style={{ color: "white", margin: "1em", padding: "1em" }}>
......
......@@ -80,7 +80,7 @@ class UnivList extends React.Component {
<Divider />
{getIndexesOnPage(page).map(
univIdx => (
<Link to={"/app/university/" + universitiesToList[univIdx].id} key={univIdx}>
<Link to={`/app/university/${universitiesToList[univIdx].id}/`} key={univIdx}>
<ListItem button divider={true} key={univIdx}>
<ListItemText primary={universitiesToList[univIdx].name} />
</ListItem>
......
import React, { Component } from "react";
import React, {Component} from "react";
import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles";
import withWidth, { isWidthUp } from "@material-ui/core/withWidth";
import withWidth, {isWidthUp} from "@material-ui/core/withWidth";
import compose from "recompose/compose";
import AppBar from "@material-ui/core/AppBar";
......@@ -13,7 +13,6 @@ import GpsFixedIcon from "@material-ui/icons/GpsFixed";
import AttachMoneyIcon from "@material-ui/icons/AttachMoney";
import HistoryIcon from "@material-ui/icons/History";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import Typography from "@material-ui/core/Typography";
import CoverGallery from "./otherComponents/CoverGallery";
......@@ -22,34 +21,9 @@ import GeneralInfoTab from "./tabs/GeneralInfoTab";
// import CampusesCitiesTab from "./tabs/CampusesCitiesTab";
import PreviousDeparturesTab from "./tabs/PreviousDeparturesTab";
import ScholarshipsTab from "./tabs/ScholarshipsTab";
// import MoreTab from "./tabs/MoreTab";
import VisibilityContext from "../common/VisibilityContext";
/**
* Component that provides information related to if the tab is selected / visible or not
*
* @param {object} props
* @returns
*/
function TabContainer(props) {
let style = { padding: 8 * 3 };
if (props.visible === false) {
style = { display: "none" };
}
return (
<VisibilityContext.Provider value={props.visible}>
<Typography component="div" style={style}>
{props.children}
</Typography>
</VisibilityContext.Provider>
);
}
import {withRouter} from "react-router-dom";
TabContainer.propTypes = {
children: PropTypes.node.isRequired,
visible: PropTypes.bool.isRequired
};
// import MoreTab from "./tabs/MoreTab";
/**
......@@ -60,16 +34,28 @@ TabContainer.propTypes = {
*/
class UniversityTemplate extends Component {
state = {
value: 0,
value: "general",
};
componentDidMount() {
// Make sure to virtually redirect to "general" if no tab is previously selected.
const {tabName} = this.props.match.params;
if (tabName === "" || typeof tabName === "undefined") {
this.handleChange(undefined, "general");
} else {
this.handleChange(undefined, tabName);
}
}
handleChange(event, value) {
this.setState({ value });
this.props.history.push(value);
this.setState({value});
}
render() {
const { classes, width } = this.props;
const { value } = this.state;
const {classes, width} = this.props,
{value} = this.state;
let scroll = true;
if (isWidthUp("lg", width)) {
......@@ -80,11 +66,11 @@ class UniversityTemplate extends Component {
<>
<div>
<CoverGallery></CoverGallery>
<CoverGallery/>
</div>
<div className={classes.root}>
<AppBar position="sticky" color="default" style={{ minHeight: "72px" }}>
<AppBar position="sticky" color="default" style={{minHeight: "72px"}}>
<Tabs
value={value}
onChange={(event, value) => this.handleChange(event, value)}
......@@ -94,20 +80,21 @@ class UniversityTemplate extends Component {
indicatorColor="primary"
textColor="primary"
>
<Tab label="Généralités" icon={<StarsIcon />} />
<Tab label="Université+" icon={<GpsFixedIcon />} />
<Tab label="Précédents départs" icon={<HistoryIcon />} />
<Tab label="Bourses" icon={<AttachMoneyIcon />} />
<Tab label="Campus & ville(s)" icon={<LocationOnIcon />} />
<Tab label="Autres" icon={<MoreHorizIcon />} />
<Tab label="Généralités" value="general" icon={<StarsIcon/>}/>
<Tab label="Université+" value="more" icon={<GpsFixedIcon/>}/>
<Tab label="Précédents départs" value="previous-exchanges" icon={<HistoryIcon/>}/>
<Tab label="Bourses" value="scholarships" icon={<AttachMoneyIcon/>}/>
<Tab label="Campus & ville(s)" value="campus-and-city" icon={<LocationOnIcon/>}/>
<Tab label="Autres" value="other" icon={<MoreHorizIcon/>}/>
</Tabs>
</AppBar>
<TabContainer visible={value === 0}> <GeneralInfoTab visible={value === 0} /> </TabContainer>
{/* {<TabContainer visible={value === 1}> <UniversityMoreTab visible={value === 1} /> </TabContainer>} */}
{<TabContainer visible={value === 2}> <PreviousDeparturesTab visible={value === 3} /> </TabContainer>}
{<TabContainer visible={value === 3}> <ScholarshipsTab visible={value === 3} /> </TabContainer>}
{/* {value === 4 && <TabContainer> <CampusesCitiesTab /> </TabContainer>} */}
{/* {value === 5 && <TabContainer> <MoreTab /> </TabContainer>} */}
<div className={classes.spacer}/>
{value === "general" ? <GeneralInfoTab/> : <></>}
{value === "more" ? <></> : <></>}
{value === "previous-exchanges" ? <PreviousDeparturesTab/> : <></>}
{value === "scholarships" ? <ScholarshipsTab/> : <></>}
{value === "campus-and-city" ? <></> : <></>}
{value === "other" ? <></> : <></>}
</div>
</>
......@@ -118,18 +105,28 @@ class UniversityTemplate extends Component {
UniversityTemplate.propTypes = {
classes: PropTypes.object.isRequired,
width: PropTypes.string.isRequired,
match: PropTypes.