Commit 4235b060 authored by Florent Chehab's avatar Florent Chehab
Browse files

New Editor setup operationnal on universityGeneralInfo

- Form component created
- Functions migrated and rewritten for cleaner ES6 use

- Notifications not working
parent ff8b40ab
Pipeline #35618 passed with stages
in 5 minutes and 9 seconds
/**
* This file contains utilities linked to the use of the API.
*/
// Stores the name of the reducers/actions that result in read data
export const successActionsWithReads = ["readSucceeded", "createSucceeded", "updateSucceeded"];
/**
* Smartly retrieve the latest read (create and update included) data from the server
*
* @export
* @param {object} stateExtract
*/
export function getLatestRead(stateExtract) {
return successActionsWithReads
.filter(action => action in stateExtract) // general handling of all types of API reducers
.map(action => stateExtract[action])
.reduce(
(prev, curr) => prev.readAt < curr.readAt ? curr : prev,
{ readAt: 0 });
}
import React, { Component } from "react"; import React, { Component } from "react";
import Loading from "./other/Loading"; import Loading from "./other/Loading";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { successActionsWithReads, getLatestRead } from "../api/utils";
// Stores the name of the reducers/actions that result in read data
const successActionsWithReads = ["readSucceeded", "createSucceeded", "updateSucceeded"];
class CustomComponentForAPI extends Component { class CustomComponentForAPI extends Component {
customErrorHandlers = {} customErrorHandlers = {}
...@@ -166,16 +163,8 @@ class CustomComponentForAPI extends Component { ...@@ -166,16 +163,8 @@ class CustomComponentForAPI extends Component {
* @memberof CustomComponentForAPI * @memberof CustomComponentForAPI
*/ */
getReadDataAndTime(propName) { getReadDataAndTime(propName) {
const prop = this.props[propName]; const prop = this.props[propName],
out = getLatestRead(prop);
// Smartly retrieve the latest data
// Stores the name of the reducers/actions that result in read data in this case
const out = successActionsWithReads
.filter(action => action in prop) // general handling of all types of API reducers
.map(action => prop[action])
.reduce(
(prev, curr) => prev.readAt < curr.readAt ? curr : prev,
{ readAt: 0 });
if (!("data" in out)) { if (!("data" in out)) {
throw Error(`No read data from the api could be retrieved for: ${propName}`); throw Error(`No read data from the api could be retrieved for: ${propName}`);
......
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles"; import withStyles from "@material-ui/core/styles/withStyles";
import compose from "recompose/compose"; import compose from "recompose/compose";
import { connect } from "react-redux"; import { connect } from "react-redux";
import Editor from "../shared/Editor"; import Editor from "../shared/Editor";
import Form from "../shared/Form";
import editorStyle from "../shared/editorStyle"; import editorStyle from "../shared/editorStyle";
import TextField from "../shared/fields/TextField"; import TextField from "../shared/fields/TextField";
...@@ -16,38 +16,30 @@ const styles = theme => ({ ...@@ -16,38 +16,30 @@ const styles = theme => ({
...editorStyle(theme) ...editorStyle(theme)
}); });
class UniversityGeneral extends Editor {
renderEditor() { class UniversityGeneralForm extends Form {
const { modelData } = this.props; render() {
return ( return (
<div> <div>
<TextField label={"Nom de l'université"} <TextField label={"Nom de l'université"}
value={modelData.name} {...this.getReferenceAndValue("name")}
required={true} required={true}
maxLength={200} maxLength={200}
formManager={this}
fieldMapping={"name"}
/> />
<TextField label={"Acronyme de l'université"} <TextField label={"Acronyme de l'université"}
value={modelData.acronym} {...this.getReferenceAndValue("acronym")}
maxLength={20} maxLength={20}
formManager={this}
fieldMapping={"acronym"}
/> />
<TextField label={"Site internet de l'université"} <TextField label={"Site internet de l'université"}
value={modelData.website} {...this.getReferenceAndValue("website")}
maxLength={300} maxLength={300}
isUrl={true} isUrl={true}
formManager={this}
fieldMapping={"website"}
/> />
<TextField label={"Logo de l'université"} <TextField label={"Logo de l'université"}
value={modelData.logo} {...this.getReferenceAndValue("logo")}
maxLength={300} maxLength={300}
isUrl={true} isUrl={true}
urlExtensions={["jpg", "png", "svg"]} urlExtensions={["jpg", "png", "svg"]}
formManager={this}
fieldMapping={"logo"}
/> />
</div> </div>
); );
...@@ -55,9 +47,14 @@ class UniversityGeneral extends Editor { ...@@ -55,9 +47,14 @@ class UniversityGeneral extends Editor {
} }
UniversityGeneral.propTypes = { class UniversityGeneralEditor extends Editor {
modelData: PropTypes.object.isRequired, renderForm() {
}; return <UniversityGeneralForm
modelData={this.props.modelData}
ref={this.formRef}
/>;
}
}
export default compose( export default compose(
...@@ -66,4 +63,4 @@ export default compose( ...@@ -66,4 +63,4 @@ export default compose(
getMapStateToPropsForEditor("universities"), getMapStateToPropsForEditor("universities"),
getMapDispatchToPropsForEditor("universities") getMapDispatchToPropsForEditor("universities")
) )
)(UniversityGeneral); )(UniversityGeneralEditor);
\ No newline at end of file
...@@ -14,22 +14,33 @@ import CustomComponentForAPI from "../../CustomComponentForAPI"; ...@@ -14,22 +14,33 @@ import CustomComponentForAPI from "../../CustomComponentForAPI";
import Alert from "./Alert"; import Alert from "./Alert";
import Notification from "./Notification"; import Notification from "./Notification";
import renderFieldsMixIn from "./editorFunctions/renderFieldsMixIn"; // import renderFieldsMixIn from "./editorFunctions/renderFieldsMixIn";
// Form is imported only for type hints
// eslint-disable-next-line no-unused-vars
import Form from "./Form";
function Transition(props) { function Transition(props) {
return <Slide direction="up" {...props} />; return <Slide direction="up" {...props} />;
} }
/**
* Class to handle editions of models on the frontend. It should be extended, eg:
*
class BlablaEditor extends Editor {
renderForm() {
return <blablaForm
modelData={this.props.modelData}
ref={this.formRef}
/>;
}
}
*
* @class Editor
* @extends {CustomComponentForAPI}
* @extends React.Component
*/
class Editor extends CustomComponentForAPI { class Editor extends CustomComponentForAPI {
fields = Object();
addField(field, fieldMapping) {
this.fields[fieldMapping] = field;
}
removeField(fieldMapping) {
delete this.fields[fieldMapping];
}
state = { state = {
alert: { alert: {
...@@ -39,209 +50,154 @@ class Editor extends CustomComponentForAPI { ...@@ -39,209 +50,154 @@ class Editor extends CustomComponentForAPI {
open: false, open: false,
}, },
lastSaveTime: this.props.lastSaveTime, lastSaveTime: this.props.lastSaveTime,
lastUpdateTime: this.props.lastUpdateTime, lastUpdateTimeInModel: this.props.lastUpdateTimeInModel,
} }
buildError(messages) { formRef = React.createRef();
return { status: messages.length > 0, messages };
}
formHasError() { /**
// to override if you need to perform some checks at the form level * Returns the form instance associated with the editor
return this.buildError(Array()); *
* @returns {Form}
* @memberof Editor
*/
getForm() {
return this.formRef.current;
} }
formFieldsHaveError() { /////////
let messages = Array(); // Shortcut functions to those of the form instance
for (let fieldKey in this.fields) { formHasError() {
const field = this.fields[fieldKey]; return this.getForm().hasError();
messages = messages.concat(field.getError().messages);
}
return this.buildError(messages);
} }
getDataFromFields() { getFormData() {
let data = Object(); return this.getForm().getDataFromFields();
for (let fieldKey in this.fields) {
const field = this.fields[fieldKey];
data[fieldKey] = field.getValue();
} }
return data;
}
hasChangesToSave(formData, modelData) {
for (let fieldKey in formData) {
let cmp1 = formData[fieldKey];
let cmp2 = modelData[fieldKey];
if (typeof cmp1 == "object") { formHasChanges() {
cmp1 = JSON.stringify(cmp1); return this.getForm().hasChanges();
cmp2 = JSON.stringify(cmp2);
} }
// End of shortcut functions
////////////////////////
if (cmp1 != cmp2) {
return true;
}
}
return false;
}
/**
* Function to save the `data` to the server.
*
* @param {Object} data
* @memberof Editor
*/
performSave(data) { performSave(data) {
this.setState({ this.notifyIsSaving();
notification: { this.props.saveData(
open: true, Object.assign({ __apiAttr: this.props.__apiAttr }, data)
message: "Enregistrement en cours", );
isLoading: true,
duration: null
} }
});
this.props.saveData(Object.assign({ __apiAttr: this.props.__apiAttr }, data));
}
handleSaveEditor() { /**
* Function to handle save editor events, eg when clicking on the save button
* This function is not trivial and checks are performed.
*
* @memberof Editor
*/
handleSaveEditorRequest() {
const formHasError = this.formHasError(); const formHasError = this.formHasError();
const formFieldsHaveError = this.formFieldsHaveError(); if (!formHasError.status) { // no error, we can save if necessary
if ((!formHasError.status) && (!formFieldsHaveError.status)) {
const formData = this.getDataFromFields(); if (this.props.forceSave || this.formHasChanges()) {
const { modelData } = this.props; // Copy the model data and copy above the data from the form
if (this.props.forceSave || this.hasChangesToSave(formData, modelData)) { // So that we don't forget anything.
const tmp = Object.assign({}, this.props.modelData, formData); this.performSave(
this.performSave(tmp); Object.assign({}, this.props.modelData, this.getFormData())
);
} else { } else {
this.setState({ this.notifyNoChangesDetected();
notification: {
open: true,
message: "Aucun changement n'a été repéré.",
success: true,
duration: 5000
}
});
this.props.handleCloseEditor(); this.props.handleCloseEditor();
} }
} else { } else {
const errors = formHasError.messages.concat(formFieldsHaveError.messages); this.notifyFormHasErrors(formHasError.messages);
this.setState({
notification: {
open: true,
message: "Le formulaire semble incohérent, merci de vérifier son contenu.",
duration: 5000,
error: true,
errors,
handleClose: this.handleCloseNotification()
} }
});
} }
handleCloseEditorRequest() {
if (this.formHasChanges()) {
this.alertChangesNotSaved();
} else {
this.props.handleCloseEditor();
} }
}
/**
* This function is extended to handle custom behaviors on component update
*
* @memberof Editor
*/
componentDidUpdate(prevProps, prevState, snapshot) { componentDidUpdate(prevProps, prevState, snapshot) {
super.componentDidUpdate(prevProps, prevState, snapshot); super.componentDidUpdate(prevProps, prevState, snapshot);
// usefull for handling moderation // To handle moderation, we use the editor in "hacky" manner
// with the `dataToSave` props, which triggers a save.
const { dataToSave } = this.props; const { dataToSave } = this.props;
if (dataToSave) { if (dataToSave) {
this.props.handleCloseEditor(); this.props.handleCloseEditor();
this.performSave(dataToSave); this.performSave(dataToSave);
} }
// end of comment // end of hacky behavior.
// we make to notify if saving to the server has errors
const { savingHasError } = this.props; const { savingHasError } = this.props;
if (savingHasError.status) { if (savingHasError.status && !this.state.alert.open) {
if (!this.state.alert.open) { this.alertSaveFailed();
this.setState({ this.handleCloseNotificationRequest(savingHasError.error);
alert: {
open: true,
info: true,
title: "L'enregistrement sur le serveur a échoué.",
description: JSON.stringify(savingHasError.error, null, 2) + "\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.",
infoText: "J'ai compris",
// handleResponse: () => this.props.clearSaveError()
}
});
this.handleCloseNotification();
}
} }
//saving data was successful
if (this.state.lastSaveTime < this.props.lastSaveTime) { if (this.state.lastSaveTime < this.props.lastSaveTime) {
//saving data was successfull
// We check if data was moderated // We check if data was moderated
let message = "Les données ont été enregistrées avec succès !"; let message,
let lastUpdateTime = this.state.lastUpdateTime; lastUpdateTimeInModel = this.state.lastUpdateTimeInModel;
const newUpdateTime = this.props.lastUpdateTime; const newUpdateTimeInModel = this.props.lastUpdateTimeInModel;
if (lastUpdateTime == newUpdateTime) {
if (lastUpdateTimeInModel == newUpdateTimeInModel) {
message = "Les données ont été enregistrées et sont en attentes de modération."; message = "Les données ont été enregistrées et sont en attentes de modération.";
} else { } else {
lastUpdateTime = newUpdateTime; lastUpdateTimeInModel = newUpdateTimeInModel;
message = "Les données ont été enregistrées avec succès !";
} }
this.setState({ lastSaveTime: this.props.lastSaveTime, lastUpdateTime }); this.setState({ lastSaveTime: this.props.lastSaveTime, lastUpdateTimeInModel });
this.setState({ this.notifySaveSuccessful(message);
notification: {
open: true,
message,
success: true,
duration: 5000,
preventClickAway: false
}
});
this.props.handleCloseEditor(true); this.props.handleCloseEditor(true);
} }
} }
handleCloseAlert() { /**
this.setState({ * Function to render the forms elements.
alert: { open: false } * Basically something that extends the `Form` component
}); *
} * @returns
* @memberof Editor
handleCloseNotification() { */
this.setState({ renderForm() {
// backup message to prevent graphic artefact throw new Error("The renderForm function must be extended in sub classes");
notification: { open: false, message: this.state.notification.message }
});
}
handleCloseEditorRequest() {
const formData = this.getDataFromFields();
const { modelData } = this.props;
if (this.hasChangesToSave(formData, modelData)) {
this.setState({
alert: {
open: true,
info: false,
title: "Modifications non enregistrées !",
description: "Vous avez des modifications qui n'ont pas été sauvegardées. Voulez-vous les enregistrer ?",
agreeText: "Oui, je les enregistre",
disagreeText: "Non",
handleResponse: (agree) => {
if (agree) {
this.handleSaveEditor();
} else {
this.props.handleCloseEditor();
}
},
}
});
} else {
this.props.handleCloseEditor();
}
}
renderEditor() {
return (<div>No editor set</div>);
} }
customRender() { customRender() {
// TODO move dialog, notification and alert to somewhere else if possible
const { classes } = this.props; const { classes } = this.props;
return ( return (
<div> <div>
<Notification <Notification
{...this.state.notification} {...this.state.notification}
handleClose={() => this.handleCloseNotification()} handleClose={() => this.handleCloseNotificationRequest()}
/> />
<Dialog <Dialog
fullScreen fullScreen
...@@ -250,7 +206,7 @@ class Editor extends CustomComponentForAPI { ...@@ -250,7 +206,7 @@ class Editor extends CustomComponentForAPI {
> >
<Alert <Alert
{...this.state.alert} {...this.state.alert}
handleClose={() => this.handleCloseAlert()} handleClose={() => this.handleCloseAlertRequest()}
/> />
<AppBar className={classes.appBar} > <AppBar className={classes.appBar} >
<Toolbar> <Toolbar>
...@@ -260,37 +216,140 @@ class Editor extends CustomComponentForAPI { ...@@ -260,37 +216,140 @@ class Editor extends CustomComponentForAPI {
<Typography variant="h6" color="inherit" className={classes.flex}>