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

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 Loading from "./other/Loading";
import PropTypes from "prop-types";
// Stores the name of the reducers/actions that result in read data
const successActionsWithReads = ["readSucceeded", "createSucceeded", "updateSucceeded"];
import { successActionsWithReads, getLatestRead } from "../api/utils";
class CustomComponentForAPI extends Component {
customErrorHandlers = {}
......@@ -166,16 +163,8 @@ class CustomComponentForAPI extends Component {
* @memberof CustomComponentForAPI
*/
getReadDataAndTime(propName) {
const prop = this.props[propName];
// 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 });
const prop = this.props[propName],
out = getLatestRead(prop);
if (!("data" in out)) {
throw Error(`No read data from the api could be retrieved for: ${propName}`);
......
import React from "react";
import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles";
import compose from "recompose/compose";
import { connect } from "react-redux";
import Editor from "../shared/Editor";
import Form from "../shared/Form";
import editorStyle from "../shared/editorStyle";
import TextField from "../shared/fields/TextField";
......@@ -16,38 +16,30 @@ const styles = theme => ({
...editorStyle(theme)
});
class UniversityGeneral extends Editor {
renderEditor() {
const { modelData } = this.props;
class UniversityGeneralForm extends Form {
render() {
return (
<div>
<TextField label={"Nom de l'université"}
value={modelData.name}
{...this.getReferenceAndValue("name")}
required={true}
maxLength={200}
formManager={this}
fieldMapping={"name"}
/>
<TextField label={"Acronyme de l'université"}
value={modelData.acronym}
{...this.getReferenceAndValue("acronym")}
maxLength={20}
formManager={this}
fieldMapping={"acronym"}
/>
<TextField label={"Site internet de l'université"}
value={modelData.website}
{...this.getReferenceAndValue("website")}
maxLength={300}
isUrl={true}
formManager={this}
fieldMapping={"website"}
/>
<TextField label={"Logo de l'université"}
value={modelData.logo}
{...this.getReferenceAndValue("logo")}
maxLength={300}
isUrl={true}
urlExtensions={["jpg", "png", "svg"]}
formManager={this}
fieldMapping={"logo"}
/>
</div>
);
......@@ -55,9 +47,14 @@ class UniversityGeneral extends Editor {
}
UniversityGeneral.propTypes = {
modelData: PropTypes.object.isRequired,
};
class UniversityGeneralEditor extends Editor {
renderForm() {
return <UniversityGeneralForm
modelData={this.props.modelData}
ref={this.formRef}
/>;
}
}
export default compose(
......@@ -66,4 +63,4 @@ export default compose(
getMapStateToPropsForEditor("universities"),
getMapDispatchToPropsForEditor("universities")
)
)(UniversityGeneral);
\ No newline at end of file
)(UniversityGeneralEditor);
......@@ -14,22 +14,33 @@ import CustomComponentForAPI from "../../CustomComponentForAPI";
import Alert from "./Alert";
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) {
return <Slide direction="up" {...props} />;
}
class Editor extends CustomComponentForAPI {
fields = Object();
addField(field, fieldMapping) {
this.fields[fieldMapping] = field;
}
removeField(fieldMapping) {
delete this.fields[fieldMapping];
/**
* 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 {
state = {
alert: {
......@@ -39,209 +50,154 @@ class Editor extends CustomComponentForAPI {
open: false,
},
lastSaveTime: this.props.lastSaveTime,
lastUpdateTime: this.props.lastUpdateTime,
lastUpdateTimeInModel: this.props.lastUpdateTimeInModel,
}
buildError(messages) {
return { status: messages.length > 0, messages };
formRef = React.createRef();
/**
* Returns the form instance associated with the editor
*
* @returns {Form}
* @memberof Editor
*/
getForm() {
return this.formRef.current;
}
/////////
// Shortcut functions to those of the form instance
formHasError() {
// to override if you need to perform some checks at the form level
return this.buildError(Array());
return this.getForm().hasError();
}
formFieldsHaveError() {
let messages = Array();
for (let fieldKey in this.fields) {
const field = this.fields[fieldKey];
messages = messages.concat(field.getError().messages);
}
return this.buildError(messages);
getFormData() {
return this.getForm().getDataFromFields();
}
getDataFromFields() {
let data = Object();
for (let fieldKey in this.fields) {
const field = this.fields[fieldKey];
data[fieldKey] = field.getValue();
}
return data;
formHasChanges() {
return this.getForm().hasChanges();
}
// End of shortcut functions
////////////////////////
hasChangesToSave(formData, modelData) {
for (let fieldKey in formData) {
let cmp1 = formData[fieldKey];
let cmp2 = modelData[fieldKey];
if (typeof cmp1 == "object") {
cmp1 = JSON.stringify(cmp1);
cmp2 = JSON.stringify(cmp2);
}
if (cmp1 != cmp2) {
return true;
}
}
return false;
}
/**
* Function to save the `data` to the server.
*
* @param {Object} data
* @memberof Editor
*/
performSave(data) {
this.setState({
notification: {
open: true,
message: "Enregistrement en cours",
isLoading: true,
duration: null
}
});
this.props.saveData(Object.assign({ __apiAttr: this.props.__apiAttr }, data));
this.notifyIsSaving();
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 formFieldsHaveError = this.formFieldsHaveError();
if ((!formHasError.status) && (!formFieldsHaveError.status)) {
const formData = this.getDataFromFields();
const { modelData } = this.props;
if (this.props.forceSave || this.hasChangesToSave(formData, modelData)) {
const tmp = Object.assign({}, this.props.modelData, formData);
this.performSave(tmp);
if (!formHasError.status) { // no error, we can save if necessary
if (this.props.forceSave || this.formHasChanges()) {
// Copy the model data and copy above the data from the form
// So that we don't forget anything.
this.performSave(
Object.assign({}, this.props.modelData, this.getFormData())
);
} else {
this.setState({
notification: {
open: true,
message: "Aucun changement n'a été repéré.",
success: true,
duration: 5000
}
});
this.notifyNoChangesDetected();
this.props.handleCloseEditor();
}
} else {
const errors = formHasError.messages.concat(formFieldsHaveError.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()
}
});
this.notifyFormHasErrors(formHasError.messages);
}
}
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) {
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;
if (dataToSave) {
this.props.handleCloseEditor();
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;
if (savingHasError.status) {
if (!this.state.alert.open) {
this.setState({
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();
}
if (savingHasError.status && !this.state.alert.open) {
this.alertSaveFailed();
this.handleCloseNotificationRequest(savingHasError.error);
}
//saving data was successful
if (this.state.lastSaveTime < this.props.lastSaveTime) {
//saving data was successfull
// We check if data was moderated
let message = "Les données ont été enregistrées avec succès !";
let lastUpdateTime = this.state.lastUpdateTime;
const newUpdateTime = this.props.lastUpdateTime;
if (lastUpdateTime == newUpdateTime) {
let message,
lastUpdateTimeInModel = this.state.lastUpdateTimeInModel;
const newUpdateTimeInModel = this.props.lastUpdateTimeInModel;
if (lastUpdateTimeInModel == newUpdateTimeInModel) {
message = "Les données ont été enregistrées et sont en attentes de modération.";
} 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({
notification: {
open: true,
message,
success: true,
duration: 5000,
preventClickAway: false
}
});
this.setState({ lastSaveTime: this.props.lastSaveTime, lastUpdateTimeInModel });
this.notifySaveSuccessful(message);
this.props.handleCloseEditor(true);
}
}
handleCloseAlert() {
this.setState({
alert: { open: false }
});
}
handleCloseNotification() {
this.setState({
// backup message to prevent graphic artefact
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>);
/**
* Function to render the forms elements.
* Basically something that extends the `Form` component
*
* @returns
* @memberof Editor
*/
renderForm() {
throw new Error("The renderForm function must be extended in sub classes");
}
customRender() {
// TODO move dialog, notification and alert to somewhere else if possible
const { classes } = this.props;
return (
<div>
<Notification
{...this.state.notification}
handleClose={() => this.handleCloseNotification()}
handleClose={() => this.handleCloseNotificationRequest()}
/>
<Dialog
fullScreen
......@@ -250,7 +206,7 @@ class Editor extends CustomComponentForAPI {
>
<Alert
{...this.state.alert}
handleClose={() => this.handleCloseAlert()}
handleClose={() => this.handleCloseAlertRequest()}
/>
<AppBar className={classes.appBar} >
<Toolbar>
......@@ -260,37 +216,140 @@ class Editor extends CustomComponentForAPI {
<Typography variant="h6" color="inherit" className={classes.flex}>
Mode édition
</Typography>
<Button color="inherit" onClick={() => this.handleSaveEditor()}>
<Button color="inherit" onClick={() => this.handleSaveEditorRequest()}>
Enregistrer
</Button>
</Toolbar>
</AppBar>
<Paper className={classes.paper}>
{this.props.open ? this.renderEditor() : <div></div>}
{this.props.open ? this.renderForm() : <div></div>}
</Paper>
</Dialog>
</div>
);
}
///////////////////
// Notification and alert related functions
// user request handlers
handleCloseNotificationRequest() {
this.setState({
// backup message to prevent graphic artefact
notification: { open: false, message: this.state.notification.message }
});
}
handleCloseAlertRequest() {
this.setState({
alert: { open: false }
});
}
// Notifications and alerts from JS
notifyNoChangesDetected() {
this.setState({
notification: {
open: true,
message: "Aucun changement n'a été repéré.",
success: true,
}
});
}
notifyFormHasErrors(errors) {
this.setState({
notification: {
open: true,
message: "Le formulaire semble incohérent, merci de vérifier son contenu.",
error: true,
errors,
handleClose: this.handleCloseNotificationRequest()
}
});
}
notifyIsSaving() {
this.setState({
notification: {
open: true,
message: "Enregistrement en cours",
isLoading: true,
duration: null
}
});
}
notifySaveSuccessful(message) {
this.setState({
notification: {
open: true,
message,
success: true,
preventClickAway: false
}
});
}
alertSaveFailed(error) {
this.setState({
alert: {
open: true,
info: true,
title: "L'enregistrement sur le serveur a échoué.",
description: JSON.stringify(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()
}
});
}
alertChangesNotSaved() {
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.handleSaveEditorRequest();
} else {
this.props.handleCloseEditor();
}
},
}
});
}
}
Object.assign(Editor.prototype, renderFieldsMixIn);
// TODO move this to form and update
// Object.assign(Editor.prototype, renderFieldsMixIn);
Editor.propTypes = {
classes: PropTypes.object.isRequired,
// clearSaveError: PropTypes.func.isRequired,
savingHasError: PropTypes.object.isRequired,
saveData: PropTypes.func.isRequired,
lastUpdateTime: PropTypes.string,
lastUpdateTimeInModel: PropTypes.string,
lastSaveTime: PropTypes.number,
forceSave: PropTypes.bool.isRequired,
outsideData: PropTypes.object,
saveShouldInvalidate: PropTypes.bool.isRequired,
__apiAttr: PropTypes.oneOf([PropTypes.number, PropTypes.string]),
__apiAttr: PropTypes.oneOf([PropTypes.number, PropTypes.string, ""]),
modelData: PropTypes.object.isRequired,
open: PropTypes.bool.isRequired,
handleCloseEditor: PropTypes.func.isRequired,
dataToSave: PropTypes.object,
};
Editor.defaultProps = {
open: false,
forceSave: false,
saveShouldInvalidate: false,
__apiAttr: "",
// eslint-disable-next-line no-console
handleCloseEditor: () => console.error("Dev forgot something...")
......
import React, { Component } from "react";
import PropTypes from "prop-types";
import areSameObjects from "../../../utils/areSameObjects";
/**
* React component that should contain `Field` instances.
* Custom function have been implemented to ease form handling.
*
* @class Form
* @extends {React.Component}
*/
class Form extends Component {
// Store it as an object to make sure we don't have issues
// with resetting it for some reason
fields = Object();
/**
* This method MUST be used on all field inside a `Form` instance.
*
* Function that returns the value corresponding to `fieldMapping` and
* a reference for the field. This reference is stored in the Form class so that
* we can easily access the fields value from the form.
*
* @param {string} fieldMapping
* @memberof Form
*/
getReferenceAndValue(fieldMapping) {
const ref = React.createRef();
this.fields[fieldMapping] = ref; // store the reference for later use
return { value: this.props.modelData[fieldMapping], ref };
}
/**
* Function that returns the fields contained in the form
* as an array of {fieldMapping: string, field: Field}
*
* Works only if the `getReferenceAndValue` was used on the Field props.
*
* @returns {Array}
* @memberof Form
*/
getFields() {
return Object.keys(this.fields)
.map(fieldMapping =>
({ fieldMapping, field: this.fields[fieldMapping].current })
);
}
/**
* Returns an object containing with {fieldMapping: valueInField}
*
* @returns {Object}
* @memberof Form
*/
getDataFromFields() {
return this.getFields()
.reduce(
(acc, { field, fieldMapping }) => {
acc[fieldMapping] = field.getValue();
return acc;
},