Commit ff8b40ab authored by Florent Chehab's avatar Florent Chehab

Merge branch 'handle_money_in_markdown' into 'master'

Handle money in markdown

See merge request !56
parents 114ea1cf 1227c0ff
Pipeline #35570 passed with stages
in 5 minutes and 35 seconds
import React from "react";
import CustomComponentForAPI from "../CustomComponentForAPI";
import React, { Component } from "react";
import { connect } from "react-redux";
import { Map, TileLayer, LayersControl, LayerGroup } from "react-leaflet";
import PropTypes from "prop-types";
import { Map, TileLayer, LayersControl, LayerGroup } from "react-leaflet";
import UnivMarkers from "./UnivMakers";
import { saveMainMapStatus } from "../../actions/map";
......@@ -12,9 +12,9 @@ import { saveMainMapStatus } from "../../actions/map";
* Component to create the map of universities
*
* @class UnivMap
* @extends {CustomComponentForAPI}
* @extends {Component}
*/
class UnivMap extends CustomComponentForAPI {
class UnivMap extends Component {
// Initial state
state = {
......@@ -22,6 +22,12 @@ class UnivMap extends CustomComponentForAPI {
height: 800,
}
constructor(props) {
super(props);
// Make sure to set the correct height on mount
this.updateDimensions();
}
/**
* Custom function to update the appropriate height of the map
*
......@@ -37,15 +43,10 @@ class UnivMap extends CustomComponentForAPI {
catch (err) { }
}
componentWillMount() {
// Make sure to set the correct height on mount
this.updateDimensions();
}
componentDidMount() {
// add an event listener to resize the map when needed
window.addEventListener("resize", this.updateDimensions.bind(this));
super.componentDidMount();
this.updateDimensions();
}
componentWillUnmount() {
......@@ -93,7 +94,7 @@ class UnivMap extends CustomComponentForAPI {
}));
}
customRender() {
render() {
const stamenName = "Stamen Watercolor",
osmFrName = "OpenStreetMap France",
esriName = "Esri WorldImagery",
......@@ -154,6 +155,11 @@ class UnivMap extends CustomComponentForAPI {
}
UnivMap.propTypes = {
map: PropTypes.object.isRequired,
saveMainMap: PropTypes.func.isRequired
};
const mapStateToProps = (state) => {
return {
map: state.app.mainMap
......
......@@ -49,6 +49,11 @@ Les objectifs de ce service sont :
| Ancien départs | ✔ |
| Départs possibles | ✔ |
| Informatio sur les universités | ✔ |
## Autre fun feature
You can format money: \`:100CHF:\` => :100CHF:
`;
......
......@@ -2,8 +2,13 @@
/* eslint-disable react/display-name */
// Inspired by : https://github.com/mui-org/material-ui/blob/master/docs/src/pages/page-layout-examples/blog/Markdown.js
import React from "react";
import React, { Component } from "react";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import ReactMarkdown from "react-markdown";
import parseMoney from "../../utils/parseMoney";
import convertAmountToEur from "../../utils/convertAmountToEur";
import withStyles from "@material-ui/core/styles/withStyles";
import Typography from "@material-ui/core/Typography";
......@@ -175,15 +180,71 @@ const renderers = {
tableBody: props => (<TableBody>{props.children}</TableBody>),
tableRow: props => (<TableRow hover={true}>{props.children}</TableRow>),
tableCell: props => (<TableCell>{props.children}</TableCell>),
thematicBreak: () => (<Divider />)
thematicBreak: () => (<Divider />),
};
export default function Markdown(props) {
return <ReactMarkdown
renderers={renderers}
allowedTypes={[...Object.keys(renderers), "text", "root", "strong"]} // Only allow custom nodes and basic ones
mode={"escape"}
{...props}
/>;
/**
* Custom Markdown component renderer to make use of material UI
*
* We don't make use of Custom Component for API since currencies should be loaded at app startup.
* We don't need to fetch them here
*
* @class Markdown
* @extends {Component}
*/
class Markdown extends Component {
/**
* Function to "compile" the markdown source
* It adds the money conversion information if the custom tag is present.
*
* @param {string} source
* @returns {string}
* @memberof Markdown
*/
compileSource(source) {
let compiled = "";
parseMoney(source).forEach(el => {
if (!el.isMoney) {
compiled += el.text;
} else {
const { amount, currency } = el,
{ currencies } = this.props;
if (currency === "EUR") {
compiled += `${amount}€`;
} else {
const converted = convertAmountToEur(amount, currency, currencies);
compiled += `${amount}${currency} [*(≈ ${converted}€)*](https://www.xe.com/currencyconverter/convert/?Amount=${amount}&From=${currency}&To=EUR)`; // add money converted information in markdown format
}
}
});
return compiled;
}
render() {
const compiledSource = this.compileSource(this.props.source);
return <ReactMarkdown
renderers={renderers}
allowedTypes={[...Object.keys(renderers), "text", "emphasis", "root", "strong"]} // Only allow custom nodes and basic ones
mode={"escape"}
source={compiledSource}
/>;
}
}
Markdown.propTypes = {
currencies: PropTypes.array.isRequired,
source: PropTypes.string
};
const mapStateToPropsTextRenderer = (state) => ({
currencies: state.api.currenciesAll.readSucceeded.data,
});
export default connect(mapStateToPropsTextRenderer)(Markdown);
......@@ -3,6 +3,7 @@ import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles";
import Markdown from "../../shared/Markdown";
import moneyConversion from "../../../utils/convertAmountToEur";
import Typography from "@material-ui/core/Typography";
const styles = theme => {
......@@ -63,9 +64,8 @@ class Scholarship extends React.Component {
}
convertAmountToEur(amount) {
const { currencies, currency } = this.props;
const rate = currencies.find(c => c.id == currency).one_EUR_in_this_currency;
return Math.trunc(amount / rate);
const { currency, currencies } = this.props;
return moneyConversion(amount, currency, currencies);
}
getAmounts() {
......
/**
* Function for converting money amounts to euros.
*
* @export
* @param {number} amount
* @param {string} currency
* @param {Array[string]} currencies
* @returns {number}
*/
export default function convertAmountToEur(amount, currency, currencies) {
if (currency === "EUR") {
return amount;
}
const rate = currencies.find(c => c.id == currency).one_EUR_in_this_currency;
return Math.trunc(amount / rate);
}
/**
* Function to get a regex object for money parsing
*
* @returns
*/
function getMoneyRegex() {
return /(?<!`):(\d*[.,]?\d*)(\w{3}):/g;
}
/**
* Parses a string to determine if there are some currency in it.
*
* For example, the string: "Hi, I earn :10.15CHF:" will be converted to:
* [{ isMoney: false, text: 'Hi, I earn ' },
* { isMoney: true, amount: '10.15', currency: 'CHF' }]
*
* In the string: amount can be an int, a float with ',' or '.' as separator
* And the currency can be in mixed case, but will always be return in uppercase.
*
* @export
* @param {string} str
* @returns {Array}
*/
export default function parseMoney(str) {
if (str === "") {
return [];
}
// reusable function
const getOutputText = (str) => ({ isMoney: false, text: str });
if (!getMoneyRegex().test(str)) {
// if the string doesn't contain anything interesting
return [getOutputText(str)];
} else {
let matches = [],
match,
moneyRegEx = getMoneyRegex();
while ((match = moneyRegEx.exec(str)) !== null) {
const matchStartIndex = match.index, // index of the starting ':'
matchLastIndex = moneyRegEx.lastIndex - 1, // index of the ending ':'
amount = parseFloat(match["1"].replace(",", ".")), // fix numbers with "," as decimal separators
currency = match["2"].toUpperCase(); // make sure the currency is uppercase
matches.push({ matchStartIndex, matchLastIndex, amount, currency });
}
let res = [], lastIndex = 0;
matches.forEach((el) => {
if (lastIndex !== el.matchStartIndex) {
// we need to add a classic string that was before the currency marker
res.push(getOutputText(str.substring(lastIndex, el.matchStartIndex)));
}
// We add the element corresponding to money mount
res.push({ isMoney: true, amount: el.amount, currency: el.currency });
lastIndex = el.matchLastIndex + 1;
});
// we need to add the eventual trailing text:
if (lastIndex !== str.length) {
res.push(getOutputText(str.substring(lastIndex)));
}
return res;
}
}
import parseMoney from "../../src/utils/parseMoney";
test("parse empty string", () => {
const str = "";
expect(parseMoney(str).length).toBe(0);
});
test("Parse string with no money", () => {
const str = "A random classic string";
expect(parseMoney(str).length).toBe(1);
expect(parseMoney(str)[0].text).toBe(str);
});
test("Parse string with only money", () => {
const str = ":100CHF:",
parsed = parseMoney(str);
expect(parsed.length).toBe(1);
expect(parsed[0].amount).toBe(100);
expect(parsed[0].currency).toBe("CHF");
});
test("Parse complicated string", () => {
const str = "Hi, I earn :0,0Chf: but he earned :100.12EUR: this year !",
parsed = parseMoney(str);
expect(parsed.length).toBe(5);
expect(parsed[0].isMoney).toBe(false);
expect(parsed[1].isMoney).toBe(true);
expect(parsed[2].isMoney).toBe(false);
expect(parsed[3].isMoney).toBe(true);
expect(parsed[4].isMoney).toBe(false);
expect(parsed[0].text).toBe("Hi, I earn ");
expect(parsed[2].text).toBe(" but he earned ");
expect(parsed[4].text).toBe(" this year !");
expect(parsed[1].amount).toBe(0);
expect(parsed[3].amount).toBe(100.12);
expect(parsed[1].currency).toBe("CHF");
expect(parsed[3].currency).toBe("EUR");
});
test("Money directly in code is returned as text", () => {
const str = "You can use `:120CHF:` to tag money infos",
parsed = parseMoney(str);
expect(parsed.length).toBe(1);
expect(parsed[0].isMoney).toBe(false);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment