Commit 4f713bd5 authored by Florent Chehab's avatar Florent Chehab

redesign(form/editor/etc.)

parent 111112f1
......@@ -35,6 +35,8 @@ import CountryService from "../../services/data/CountryService";
import CurrencyService from "../../services/data/CurrencyService";
import LanguageService from "../../services/data/LanguageService";
import FilterService from "../../services/FilterService";
import AlertServiceComponent from "../services/AlertServiceComponent";
import AlertService from "../../services/AlertService";
const SERVICES_TO_INITIALIZE = [
UniversityService,
......@@ -66,6 +68,7 @@ function App() {
}}
>
<MainAppFrame>
<AlertServiceComponent ref={AlertService.setComponent} />
<FullScreenDialogServiceComponent
ref={FullScreenDialogService.setComponent}
/>
......
/**
* Class to handle app errors (such as form errors) in a nice wrapped way
* Class to handle app errors (such as _form errors) in a nice wrapped way
* It's for non fatal errors.
*
* @export
......
import React from "react";
import React, { useEffect } from "react";
import PropTypes from "prop-types";
import Alert from "./Alert";
import useDeleteOne from "../../hooks/useDeleteOne";
import AlertService from "../../services/AlertService";
// TODO component is Useless...
function DeleteHandler({ performClose, route, id }) {
const performDelete = useDeleteOne(route);
return (
<Alert
open
info={false}
title="Confirmer la suppression de l'object."
description="Ếtes-vous sûr⋅e ?"
agreeText="Oui"
disagreeText="Non"
handleClose={performClose}
handleResponse={confirmed => {
useEffect(() => {
AlertService.open({
info: false,
title: "Confirmer la suppression de l'object.",
description: "Ếtes-vous sûr⋅e ?",
agreeText: "Oui",
disagreeText: "Non",
handleClose: { performClose },
handleResponse: confirmed => {
if (confirmed) performDelete(id, () => performClose());
else performClose();
}}
/>
);
}
});
}, []);
return <></>;
}
DeleteHandler.propTypes = {
......
......@@ -6,14 +6,13 @@ import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import RequestParams from "../../redux/api/RequestParams";
import getActions from "../../redux/api/getActions";
import Alert from "./Alert";
import APP_ROUTES from "../../config/appRoutes";
import AlertService from "../../services/AlertService";
function clear() {
return {
error: null,
errorInfo: null,
alertOpen: true
errorInfo: null
};
}
......@@ -27,8 +26,7 @@ class ErrorBoundary extends React.Component {
// Catch errors in any components below and re-render with error message
this.setState({
error,
errorInfo,
alertOpen: true
errorInfo
});
const data = "stack" in error ? { componentStack: error.stack } : errorInfo;
......@@ -43,25 +41,23 @@ class ErrorBoundary extends React.Component {
if (errorInfo) {
// eslint-disable-next-line no-console
console.log(error, errorInfo);
return (
<Alert
open={this.state.alertOpen}
info={false}
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; l'erreur leur a été transmise."
}
agreeText={"C'est noté, je sais que vous faîtes de votre mieux :)"}
disagreeText={"Retourner à l'accueil"}
handleResponse={agreed => {
// May need to click twice, but there seem to be no other ways
if (!agreed) this.props.history.push(APP_ROUTES.base);
}}
handleClose={() => this.setState(clear())}
/>
);
AlertService.open({
info: false,
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; l'erreur leur a été transmise.",
agreeText: "C'est noté, je sais que vous faîtes de votre mieux :)",
disagreeText: "Retourner à l'accueil",
handleResponse: agreed => {
this.setState(clear());
// May need to click twice, but there seem to be no other ways
if (!agreed) this.props.history.push(APP_ROUTES.base);
}
});
return <></>;
}
// Normally, just render children
......
import React from "react";
import AppBar from "@material-ui/core/AppBar/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import IconButton from "@material-ui/core/IconButton";
import CloseIcon from "@material-ui/icons/Close";
import Typography from "@material-ui/core/Typography";
import Paper from "@material-ui/core/Paper";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/styles";
const useStyles = makeStyles(theme => ({
appBar: {
position: "sticky"
},
flex: {
flex: 1
},
paper: {
padding: theme.spacing(2),
margin: theme.spacing(2)
}
}));
function FullScreenDialogFrame({
children,
rightButton,
handleCloseRequest,
title
}) {
const classes = useStyles();
return (
<>
<AppBar className={classes.appBar}>
<Toolbar>
<IconButton
color="inherit"
onClick={handleCloseRequest}
aria-label="Close"
>
<CloseIcon />
</IconButton>
<Typography variant="h6" color="inherit" className={classes.flex}>
{title}
</Typography>
{rightButton}
</Toolbar>
</AppBar>
<Paper className={classes.paper}>{children}</Paper>
</>
);
}
FullScreenDialogFrame.propTypes = {
children: PropTypes.node.isRequired,
handleCloseRequest: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
rightButton: PropTypes.node
};
FullScreenDialogFrame.defaultProps = {
rightButton: <></>
};
export default FullScreenDialogFrame;
import React, { useEffect } from "react";
import PropTypes from "prop-types";
import compose from "recompose/compose";
import Button from "@material-ui/core/Button";
import MobileStepper from "@material-ui/core/MobileStepper";
import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft";
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight";
import { makeStyles } from "@material-ui/styles";
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import RequestParams from "../../redux/api/RequestParams";
import FullScreenDialogService from "../../services/FullScreenDialogService";
import useStepper from "../../hooks/useStepper";
import useInvalidateAll from "../../hooks/useInvalidateAll";
import FullScreenDialogFrame from "../common/FullScreenDialogFrame";
import dateTimeStrToStr from "../../utils/dateTimeStrToStr";
import withNetworkWrapper, {
getApiPropTypes,
NetWrapParam
} from "../../hoc/withNetworkWrapper";
const useStyles = makeStyles(theme => ({
editButton: {
display: "block",
marginLeft: "auto",
marginRight: "auto",
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2)
}
}));
function VersionInfo({
rawModelData,
versionNumber,
totalNbVersions,
editFromVersion
}) {
const classes = useStyles();
let dateInfo = <em>(Information non connue.)</em>;
const { updated_on } = rawModelData;
if (updated_on) {
const data = dateTimeStrToStr(updated_on);
dateInfo = `${data.date} à ${data.time}`;
}
return (
<>
<Typography variant="caption" align="center">
Les versions successives d'un même utilisateur ne sont pas enregistrés
(dans de tels cas, seul la dernière est conservée).
</Typography>
<Typography variant="h6" align="center">
{`Version n°${totalNbVersions - versionNumber} du ${dateInfo}`}
</Typography>
<Button
variant="outlined"
color="primary"
className={classes.editButton}
onClick={() => editFromVersion(rawModelData)}
>
Éditer à partir de cette version
</Button>
</>
);
}
VersionInfo.propTypes = {
rawModelData: PropTypes.object.isRequired,
totalNbVersions: PropTypes.number.isRequired,
versionNumber: PropTypes.number.isRequired,
editFromVersion: PropTypes.func.isRequired
};
const buildParams = modelInfo => {
const { contentTypeId, id } = modelInfo;
return RequestParams.Builder.withEndPointAttrs([contentTypeId, id]).build();
};
// TODO check what is displayed
function History({
versions,
modelInfo,
api,
renderTitle,
renderCore,
editFromVersion,
rawModelDataEx
}) {
const resetVersions = useInvalidateAll("versions");
useEffect(() => {
return resetVersions(); // only on unmount
}, []);
// keep the component up to date
useEffect(() => {
api.versions.setParams(buildParams(modelInfo));
}, [modelInfo]);
const [currentVersion, goNextVersion, goPreviousVersion] = useStepper(1);
const maxSteps = versions.length;
const newRawModelData = { ...rawModelDataEx, ...versions[currentVersion] };
return (
<FullScreenDialogFrame
handleCloseRequest={FullScreenDialogService.closeDialog}
title="Parcours de l'historique"
>
<>
<MobileStepper
steps={versions.length}
position="static"
activeStep={currentVersion}
nextButton={
<Button
color="secondary"
size="small"
onClick={goNextVersion}
disabled={currentVersion >= maxSteps - 1}
>
Version suivante
<KeyboardArrowRight />
</Button>
}
backButton={
<Button
color="secondary"
size="small"
onClick={goPreviousVersion}
disabled={currentVersion === 0}
>
<KeyboardArrowLeft />
Version précédente
</Button>
}
/>
<br />
<Divider />
<VersionInfo
rawModelData={newRawModelData}
versionNumber={currentVersion}
totalNbVersions={versions.length}
editFromVersion={editFromVersion}
/>
<br />
{renderTitle(newRawModelData)}
{renderCore(newRawModelData)}
</>
</FullScreenDialogFrame>
);
}
History.propTypes = {
editFromVersion: PropTypes.func.isRequired,
versions: PropTypes.array.isRequired,
modelInfo: PropTypes.shape({
contentTypeId: PropTypes.number.isRequired,
id: PropTypes.number.isRequired
}).isRequired,
renderCore: PropTypes.func.isRequired,
renderTitle: PropTypes.func.isRequired,
rawModelDataEx: PropTypes.object.isRequired,
api: getApiPropTypes("versions").isRequired
};
export default compose(
withNetworkWrapper([
new NetWrapParam("versions", "all", "versions", props =>
buildParams(props.modelInfo)
)
])
)(History);
import React, { useCallback, useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import compose from "recompose/compose";
import Button from "@material-ui/core/Button";
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import { makeStyles } from "@material-ui/styles";
import RequestParams from "../../redux/api/RequestParams";
import FullScreenDialogService from "../../services/FullScreenDialogService";
import FullScreenDialogFrame from "../common/FullScreenDialogFrame";
import withNetworkWrapper, {
getApiPropTypes,
NetWrapParam
} from "../../hoc/withNetworkWrapper";
import useInvalidateAll from "../../hooks/useInvalidateAll";
const useStyles = makeStyles(theme => ({
editButton: {
display: "block",
marginLeft: "auto",
marginRight: "auto",
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2)
}
}));
function PendingModerationInfo({ userCanModerate, moderate, editFromPending }) {
const classes = useStyles();
return (
<>
<Button
variant="contained"
color="primary"
className={classes.editButton}
onClick={editFromPending}
>
Éditer à partir de cette version
</Button>
{!userCanModerate && (
<Typography variant="caption" align="center">
Vous n'avez pas les droits nécessaires pour modérer cet élément.
</Typography>
)}
<Button
variant="outlined"
color="primary"
disabled={!userCanModerate}
className={classes.editButton}
onClick={moderate}
>
Valider cette version
</Button>
</>
);
}
PendingModerationInfo.propTypes = {
userCanModerate: PropTypes.bool.isRequired,
moderate: PropTypes.func.isRequired,
editFromPending: PropTypes.func.isRequired
};
const buildParams = modelInfo => {
const { contentTypeId, id } = modelInfo;
return RequestParams.Builder.withEndPointAttrs([contentTypeId, id]).build();
};
function PendingModeration({
api,
modelInfo,
pendingModeration,
moderatePendingModeration,
editFromPendingModeration,
renderTitle,
renderCore,
userCanModerate
}) {
const resetData = useInvalidateAll("pendingModerationObj");
useEffect(() => {
return resetData();
}, []);
// keep the component up to date
useEffect(() => {
api.pendingModeration.setParams(buildParams(modelInfo));
}, [modelInfo]);
const pendingModelData = useMemo(() => {
return {
...pendingModeration.new_object,
id: pendingModeration.object_id
};
}, [pendingModeration]);
const moderate = useCallback(() => {
moderatePendingModeration(pendingModelData);
}, [pendingModelData]);
const editFromPending = useCallback(() => {
editFromPendingModeration(pendingModelData);
}, [pendingModelData]);
return (
<FullScreenDialogFrame
handleCloseRequest={FullScreenDialogService.closeDialog}
title="Version en attente de modération"
>
<PendingModerationInfo
userCanModerate={userCanModerate}
moderate={moderate}
editFromPending={editFromPending}
/>
<Divider />
<br />
{renderTitle(pendingModelData)}
{renderCore(pendingModelData)}
</FullScreenDialogFrame>
);
}
PendingModeration.propTypes = {
modelInfo: PropTypes.shape({
contentTypeId: PropTypes.number.isRequired,
id: PropTypes.number.isRequired
}).isRequired,
editFromPendingModeration: PropTypes.func.isRequired,
moderatePendingModeration: PropTypes.func.isRequired,
userCanModerate: PropTypes.bool.isRequired,
pendingModeration: PropTypes.object.isRequired,
renderTitle: PropTypes.func.isRequired,
renderCore: PropTypes.func.isRequired,
api: getApiPropTypes("pendingModeration").isRequired
};
export default compose(
withNetworkWrapper([
new NetWrapParam(
"pendingModerationObj",
"all",
"pendingModeration",
props => buildParams(props.modelInfo)
)
])
)(PendingModeration);
......@@ -17,7 +17,6 @@ class BooleanField extends Field {
/**
* @override
* @returns
* @memberof BooleanField
*/
getError() {
// There are no possible errors, but we need to redefine this function
......@@ -31,7 +30,6 @@ class BooleanField extends Field {
/**
* @override
* @returns
* @memberof BooleanField
*/
renderField() {
const checked = this.state.value;
......
......@@ -3,26 +3,34 @@ import PropTypes from "prop-types";
import FieldWrapper from "./FieldWrapper";
// eslint-disable-next-line no-unused-vars
import CustomError from "../../common/CustomError";
import FormContext from "../../../contexts/FormContext";
/**
* Class that handle fields logic
*
* Hard to hookify as there are many interactions and the need to use a FieldWrapper.
*
* @abstract
* @class Field
* @extends {PureComponent}
*/
class Field extends PureComponent {
// eslint-disable-next-line react/static-property-placement
static contextType = FormContext;
// Attribute that will be used to replace null value
defaultNullValue = undefined;
constructor(props) {
super(props);
constructor(props, context) {
super(props, context);
// make sure to subscribe to the form ! IMPORTANT
props.form.fieldSubscribe(props.fieldMapping, this);
// make sure to subscribe to the _form ! IMPORTANT
this.context.formManager.fieldSubscribe(props.fieldMapping, this);
this.state = {
value: props.value
value: this.context.formManager.getInitialValueForMapping(
props.fieldMapping
)
};
}
......@@ -39,9 +47,9 @@ class Field extends PureComponent {
value: newState.value === this.defaultNullValue ? null : newState.value
});
}
const { form, fieldMapping } = this.props;
const { fieldMapping } = this.props;
super.setState(newState, () => {
form.fieldUpdated(fieldMapping);
this.context.formManager.fieldUpdated(fieldMapping);
});
}
......@@ -58,12 +66,14 @@ class Field extends PureComponent {
/**
* YOU SHOULDN'T OVERRIDE THIS
*
* Return the errors from the field and the errors from the form corresponding to the field.
* Return the errors from the field and the errors from the _form corresponding to the field.
* @returns {CustomError}
*/
getAllErrors() {
const { form, fieldMapping } = this.props;
return this.getError().combine(form.getErrorForField(fieldMapping));
const { fieldMapping } = this.props;
return this.getError().combine(
this.context.formManager.getErrorForField(fieldMapping)
);
}
/**
......@@ -119,16 +129,13 @@ class Field extends PureComponent {
Field.defaultProps = {
required: false,
label: "mon label",
comment: "",
value: undefined
comment: ""
};
Field.propTypes = {
required: PropTypes.bool, // is the field required ?
label: PropTypes.string, // text to go along the field
comment: PropTypes.string, // text to give more information on what is expected
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), // value of the field
form: PropTypes.object.isRequired, // required in constructor (reference of to the) form containing the field
fieldMapping: PropTypes.string.isRequired // name of the field in the data
};
......
......@@ -18,8 +18,8 @@ import DownshiftMultiple from "../../common/DownshiftMultiple";
* @extends {Field}
*/
class MultiSelectField extends Field {
constructor(props) {
super(props);
constructor(props, context) {
super(props, context);
this.optionsByValue = {};
// eslint-disable-next-line no-return-assign
props.options.forEach(opt => (this.optionsByValue[opt.value] = opt.label));
......
......@@ -258,7 +258,9 @@ UsefulLinksField.defaultProps = {
...Field.defaultProps,
value: [],
urlMaxLength: 300,
descriptionMaxLength: 50
descriptionMaxLength: 50,
label: "Lien(s) utile(s) (ex : vers ces informations)",
fieldMapping: "useful_links"
};
UsefulLinksField.propTypes = {
......