Commit d1e7419f authored by Alexandre Lanceart's avatar Alexandre Lanceart Committed by Florent Chehab

feature(filter)

* Filtering universities is now possible based on the country of the university
* Filters are synchronized between the map and the search page

Fixes #13
In progress: #31
parent 1dd31731
Pipeline #39545 passed with stages
in 3 minutes and 32 seconds
/**
*
* WARNING THIS FILE HAS NOT BEEN REVIEWED AS OF 22.02.2019
* THINGS MIGHT NOT BE SUPER CLEAR OR BROKEN
*
*
*
*
*
*
*
*
*
*
*
*
*/
// Inspired by : https://material-ui.com/demos/autocomplete/
import React from "react";
......@@ -30,19 +9,19 @@ import TextField from "@material-ui/core/TextField";
import Paper from "@material-ui/core/Paper";
import MenuItem from "@material-ui/core/MenuItem";
import Chip from "@material-ui/core/Chip";
import __map from "lodash/map";
import __difference from "lodash/difference";
import fuzzysort from "fuzzysort";
import isEqual from "lodash/isEqual";
function renderInput(inputProps) {
const { InputProps, classes, ref, ...other } = inputProps;
const {InputProps, classes, ref, ...other} = inputProps;
return (
<TextField
InputProps={{
inputRef: ref,
classes: { root: classes.inputRoot },
classes: {root: classes.inputRoot},
...InputProps,
}}
{...other}
......@@ -50,7 +29,7 @@ function renderInput(inputProps) {
);
}
function renderSuggestion({ suggestion, index, itemProps, highlightedIndex, selectedItem }) {
function renderSuggestion({suggestion, index, itemProps, highlightedIndex, selectedItem}) {
const isHighlighted = highlightedIndex === index;
const isSelected = (selectedItem || "").indexOf(suggestion) > -1;
......@@ -68,17 +47,22 @@ function renderSuggestion({ suggestion, index, itemProps, highlightedIndex, sele
</MenuItem>
);
}
renderSuggestion.propTypes = {
highlightedIndex: PropTypes.number,
index: PropTypes.number,
itemProps: PropTypes.object,
selectedItem: PropTypes.string,
suggestion: PropTypes.shape({ label: PropTypes.string, id: PropTypes.number }).isRequired,
suggestion: PropTypes.shape({label: PropTypes.string, id: PropTypes.number}).isRequired,
};
class DownshiftMultiple extends React.Component {
state = this.props.config;
constructor(props) {
super(props);
this.state = Object.assign({}, this.props.config);
}
componentDidMount() {
this.setState(this.props.config);
......@@ -86,7 +70,11 @@ class DownshiftMultiple extends React.Component {
// eslint-disable-next-line no-unused-vars
componentDidUpdate(prevProps, prevState, snapshot) {
if (this.state.selectedItems != prevState.selectedItems) {
if (!isEqual(prevProps.config.selectedItems, this.props.config.selectedItems)) {
this.setState(this.props.config);
}
if (this.state.selectedItems !== prevState.selectedItems) {
this.props.onChange(this.state.selectedItems);
}
}
......@@ -99,19 +87,19 @@ class DownshiftMultiple extends React.Component {
getSuggestions(value) {
const { options } = this.props;
const { selectedItems } = this.state;
const {options} = this.props;
const {selectedItems} = this.state;
let possible = __difference(options, selectedItems);
const filter = fuzzysort.go(value, possible, { limit: 5, key: "label" });
const filter = fuzzysort.go(value, possible, {limit: 5, key: "label"});
if (filter.length > 0) {
return __map(filter, (item) => item.obj);
return filter.map(item => item.obj);
} else {
return possible.slice(0, 4);
}
}
handleKeyDown = event => {
const { inputValue, selectedItems } = this.state;
const {inputValue, selectedItems} = this.state;
if (selectedItems.length && !inputValue.length && keycode(event) === "backspace") {
this.setState({
selectedItems: selectedItems.slice(0, selectedItems.length - 1),
......@@ -120,12 +108,12 @@ class DownshiftMultiple extends React.Component {
};
handleInputChange = event => {
this.setState({ inputValue: event.target.value });
this.setState({inputValue: event.target.value});
};
handleChange = item => {
const { options } = this.props;
let { selectedItems } = this.state;
const {options} = this.props;
let {selectedItems} = this.state;
if (selectedItems.indexOf(item) === -1) {
for (let ind in options) {
let el = options[ind];
......@@ -146,30 +134,23 @@ class DownshiftMultiple extends React.Component {
this.setState(state => {
const selectedItems = [...state.selectedItems];
selectedItems.splice(selectedItems.indexOf(item), 1);
return { selectedItems };
return {selectedItems};
});
};
render() {
const { classes, field_label, field_placeholder } = this.props;
const { inputValue, selectedItems } = this.state;
const {classes, fieldLabel, fieldPlaceholder} = this.props;
const {inputValue, selectedItems} = this.state;
return (
<div className={classes.root}>
<Downshift
id="downshift-multiple"
inputValue={inputValue}
onChange={this.handleChange}
selectedItem={selectedItems}
>
{
({
getInputProps,
getItemProps,
isOpen,
inputValue: inputValue2,
selectedItem: selectedItem2,
highlightedIndex,
}) =>
({getInputProps, getItemProps, isOpen, inputValue: inputValue2, selectedItem: selectedItem2, highlightedIndex,}) =>
(
<div className={classes.container}>
{renderInput({
......@@ -189,9 +170,9 @@ class DownshiftMultiple extends React.Component {
)),
onChange: this.handleInputChange,
onKeyDown: this.handleKeyDown,
placeholder: field_placeholder,
placeholder: fieldPlaceholder,
}),
label: field_label,
label: fieldLabel,
})}
{isOpen ? (
<Paper className={classes.paper} square>
......@@ -199,7 +180,7 @@ class DownshiftMultiple extends React.Component {
renderSuggestion({
suggestion,
index,
itemProps: getItemProps({ item: suggestion.id }),
itemProps: getItemProps({item: suggestion.id}),
highlightedIndex,
selectedItem: selectedItem2,
}),
......@@ -221,27 +202,20 @@ DownshiftMultiple.propTypes = {
onComponentUnmount: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
config: PropTypes.object.isRequired,
field_label: PropTypes.string.isRequired,
field_placeholder: PropTypes.string.isRequired,
fieldLabel: PropTypes.string.isRequired,
fieldPlaceholder: PropTypes.string.isRequired,
options: PropTypes.array.isRequired,
};
DownshiftMultiple.defaultProps = {
options: [
{ label: "Item 1", id: 1 },
{ label: "Item 2", id: 2 },
{ label: "Item 3", id: 3 },
{ label: "Item 4", id: 4 },
],
field_label: "label",
field_placeholder: "placeholder",
config: { selectedItems: [], inputValue: "" }
fieldLabel: "",
fieldPlaceholder: "Filtrer par Pays",
};
const styles = theme => ({
root: {
flexGrow: 1,
height: 250,
zIndex: 100000,
},
container: {
flexGrow: 1,
......
/**
*
* WARNING THIS FILE HAS NOT BEEN REVIEWED AS OF 22.02.2019
* THINGS MIGHT NOT BE SUPER CLEAR OR BROKEN
*
*
*
*
*
*
*
*
*
*
*
*
*/
import React from "react";
import PropTypes from "prop-types";
import DownshiftMultiple from "./DownshiftMultiple";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import { connect } from "react-redux";
import __map from "lodash/map";
import __indexOf from "lodash/indexOf";
import { saveSelectedUniversities, saveFilterConfig } from "../../redux/actions/filter";
import {connect} from "react-redux";
import {saveFilterConfig, saveSelectedUniversities} from "../../redux/actions/filter";
import ExpansionPanel from "@material-ui/core/ExpansionPanel";
import ExpansionPanelSummary from "@material-ui/core/ExpansionPanelSummary";
import ExpansionPanelDetails from "@material-ui/core/ExpansionPanelDetails";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import Typography from "@material-ui/core/Typography";
import withStyles from "@material-ui/core/styles/withStyles";
import InfoIcon from "@material-ui/icons/InfoOutlined";
import getActions from "../../redux/api/getActions";
import {compose} from "recompose";
let isOpen = false;
let hasSelection = false;
/**
* Implementation of a filter component
......@@ -48,8 +28,9 @@ import getActions from "../../redux/api/getActions";
*/
class Filter extends CustomComponentForAPI {
saveCountriesFilterConfig(state) {
this.props.saveConfig({ countriesFilter: state });
this.props.saveConfig({countriesFilter: state});
}
/**
......@@ -58,44 +39,66 @@ class Filter extends CustomComponentForAPI {
* @returns {Array} of the countries instances
* @memberof Filter
*/
getCountriesWhereThereAreUniversities() {
const mainCampuses = this.getLatestReadData("mainCampuses");
const {mainCampuses, cities, countries} = this.getLatestReadDataFor(["mainCampuses", "cities", "countries"]);
let citiesMap = new Map(),
countriesMap = new Map();
cities.forEach(el => citiesMap.set(el.id, el));
countries.forEach(el => countriesMap.set(el.id, el));
// use of map to get only each country once
let res = new Map();
mainCampuses.forEach(campus => {
const country = this.joinCampus(campus).country,
code = country.iso_alpha2_code;
res.set(code, country);
});
mainCampuses.forEach(el => {
const cityId = el.city,
city = citiesMap.get(cityId),
countryId = city.country,
country = countriesMap.get(countryId);
res.set(countryId, country);
});
return [...res.values()];
}
updateSelectedUniversities(selection) {
const mainCampuses = this.getLatestReadData("mainCampuses"),
listOfCountries = __map(selection, (s) => s.id);
listOfCountries = selection.map(s => s.id);
let selectedUniversities = [];
mainCampuses.forEach(campus => {
const campusFull = this.joinCampus(campus);
if (__indexOf(listOfCountries, campusFull.country.iso_alpha2_code) > -1) {
if (listOfCountries.includes(campusFull.country.iso_alpha2_code)) {
selectedUniversities.push(campusFull.university.id);
}
});
this.props.saveSelection(selectedUniversities);
hasSelection = selectedUniversities.length !== 0;
this.forceUpdate();
}
customRender() {
const options = __map(this.getCountriesWhereThereAreUniversities(),
(c) => { return { id: c.iso_alpha2_code, label: c.name }; });
const { classes } = this.props;
const options = this.getCountriesWhereThereAreUniversities()
.map(c => ({id: c.iso_alpha2_code, label: c.name}));
const {classes} = this.props;
return (
<ExpansionPanel>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<ExpansionPanel expanded={isOpen} onChange={() => {
isOpen = !isOpen;
this.forceUpdate();
}}>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon/>}>
<Typography className={classes.heading}>Appliquer des filtres</Typography>
<div className={classes.infoFilter}>
<InfoIcon color={hasSelection ? "primary" : "disabled"}/>
<Typography className={classes.caption} color={hasSelection ? "primary" : "textSecondary"}>
<em>{hasSelection ? "(Un filtre est actif)" : "(Aucun filtre est actif)"}</em>
</Typography>
</div>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<DownshiftMultiple
......@@ -114,7 +117,7 @@ Filter.propTypes = {
classes: PropTypes.object.isRequired,
saveConfig: PropTypes.func.isRequired,
saveSelection: PropTypes.func.isRequired,
countriesFilterConfig: PropTypes.array
countriesFilterConfig: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
......@@ -123,7 +126,7 @@ const mapStateToProps = (state) => {
mainCampuses: state.api.mainCampusesAll,
cities: state.api.citiesAll,
countries: state.api.countriesAll,
countriesFilterConfig: state.app.filter.countriesFilter
countriesFilterConfig: state.app.filter.countriesFilter,
};
};
......@@ -147,8 +150,15 @@ const styles = theme => ({
},
heading: {
fontSize: theme.typography.pxToRem(15),
fontWeight: theme.typography.fontWeightRegular,
fontWeight: theme.typography.fontWeightMedium,
},
infoFilter: {
marginLeft: 2 * theme.spacing.unit,
display: "inherit"
}
});
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(Filter));
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withStyles(styles)
)(Filter);
import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { Marker, Popup } from "react-leaflet";
import {connect} from "react-redux";
import {Marker, Popup} from "react-leaflet";
import UnivPopupContent from "./UnivPopupContent";
import getActions from "../../redux/api/getActions";
......@@ -10,7 +10,6 @@ import CustomComponentForAPI from "../common/CustomComponentForAPI";
import arrayOfInstancesToMap from "../../utils/arrayOfInstancesToMap";
/**
* Class that renders the markers for the map of the universities.
*
......@@ -23,10 +22,12 @@ import arrayOfInstancesToMap from "../../utils/arrayOfInstancesToMap";
class UnivMarkers extends CustomComponentForAPI {
customRender() {
const { universities,
const {
universities,
mainCampuses,
countries,
cities } = this.getAllLatestReadData();
cities
} = this.getAllLatestReadData();
// some conversions for optimization (faster search of element in a map)
const universitiesMap = arrayOfInstancesToMap(universities),
......@@ -34,23 +35,30 @@ class UnivMarkers extends CustomComponentForAPI {
citiesMap = arrayOfInstancesToMap(cities);
let mainCampusesSelection = [];
const listUnivsel = this.props.selectedUniversities;
//console.log(this.props.selectedUniversities);
// Merge the data and add it to the selection
mainCampuses.forEach(campus => {
const univ = universitiesMap.get(campus.university);
if (campus && univ) {
const city = citiesMap.get(campus.city),
country = countriesMap.get(city.country);
mainCampusesSelection.push({
univName: univ.name,
univLogo: univ.logo,
univCity: city.name,
univCountry: country.name,
lat: campus.lat,
lon: campus.lon,
id: univ.id
});
if ((listUnivsel.length > 0 && listUnivsel.indexOf(univ.id) > -1) || listUnivsel.length === 0) {
if (campus && univ) {
const city = citiesMap.get(campus.city),
country = countriesMap.get(city.country);
mainCampusesSelection.push({
univName: univ.name,
univLogo: univ.logo,
univCity: city.name,
univCountry: country.name,
lat: campus.lat,
lon: campus.lon,
id: univ.id
});
}
}
});
......@@ -58,7 +66,7 @@ class UnivMarkers extends CustomComponentForAPI {
return (
mainCampusesSelection.map((el, idx) => (
<Marker key={idx} position={[el.lat, el.lon]}>
<Popup closeButton={false} >
<Popup closeButton={false}>
<UnivPopupContent
name={el.univName}
logo={el.univLogo}
......@@ -79,6 +87,7 @@ UnivMarkers.propTypes = {
mainCampuses: PropTypes.object.isRequired,
cities: PropTypes.object.isRequired,
countries: PropTypes.object.isRequired,
selectedUniversities: PropTypes.array.isRequired
};
const mapStateToProps = (state) => {
......@@ -86,7 +95,8 @@ const mapStateToProps = (state) => {
universities: state.api.universitiesAll,
mainCampuses: state.api.mainCampusesAll,
cities: state.api.citiesAll,
countries: state.api.countriesAll
countries: state.api.countriesAll,
selectedUniversities: state.app.selectedUniversities
};
};
......
import React, { Component } from "react";
import { connect } from "react-redux";
import React, {Component} from "react";
import {connect} from "react-redux";
import PropTypes from "prop-types";
import { Map, TileLayer, LayersControl, LayerGroup } from "react-leaflet";
import {LayerGroup, LayersControl, Map, TileLayer} from "react-leaflet";
import UnivMarkers from "./UnivMakers";
import { saveMainMapStatus } from "../../redux/actions/map";
import {saveMainMapStatus} from "../../redux/actions/map";
/**
......@@ -20,7 +20,7 @@ class UnivMap extends Component {
state = {
leafletInstance: null,
height: 800,
}
};
constructor(props) {
super(props);
......@@ -36,10 +36,11 @@ class UnivMap extends Component {
updateDimensions() {
try {
const height = window.innerHeight - document.getElementById("MySuperMap").getBoundingClientRect().y;
this.setState({ height: Math.round(0.9 * height) });
this.setState({height: Math.round(0.9 * height)});
}
// eslint-disable-next-line no-empty
catch (err) { }
catch (err) {
}
}
componentDidMount() {
......@@ -50,7 +51,7 @@ class UnivMap extends Component {
componentWillUnmount() {
// Save the state of the map to the redux store so that it is restored easily
const { leafletInstance } = this.state;
const {leafletInstance} = this.state;
if (leafletInstance) {
let selectedLayer = "";
if (this.state.selectedLayer) {
......@@ -97,58 +98,61 @@ class UnivMap extends Component {
const stamenName = "Stamen Watercolor",
osmFrName = "OpenStreetMap France",
esriName = "Esri WorldImagery",
{ selectedLayer, zoom, center } = this.props.map,
{ height } = this.state;
{selectedLayer, zoom, center} = this.props.map,
{height} = this.state;
// Create the map and add the layers and markers
return (
<Map id={"MySuperMap"} center={center} zoom={zoom} style={{ height }} whenReady={(e) => this.saveLeafletInstance(e.target)} onBaselayerchange={(e) => this.saveSelectedLayer(e)}>
<>
<Map id={"MySuperMap"} center={center} zoom={zoom} style={{height}}
whenReady={(e) => this.saveLeafletInstance(e.target)} onBaselayerchange={(e) => this.saveSelectedLayer(e)}>
<LayersControl position="topright">
<LayersControl position="topright">
<LayersControl.BaseLayer name={osmFrName} checked={selectedLayer === osmFrName}>
<TileLayer
attribution='&copy; Openstreetmap France | &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png"
maxZoom={20}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={stamenName} checked={selectedLayer === stamenName}>
{/* Need to overlay 2 layers for this one */}
<LayerGroup>
<LayersControl.BaseLayer name={osmFrName} checked={selectedLayer === osmFrName}>
<TileLayer
attribution='Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> &mdash; Map data &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.png"
minZoom={1}
maxZoom={18}
subdomains='abcd'
attribution='&copy; Openstreetmap France | &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png"
maxZoom={20}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={stamenName} checked={selectedLayer === stamenName}>
{/* Need to overlay 2 layers for this one */}
<LayerGroup>
<TileLayer
attribution='Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> &mdash; Map data &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.png"
minZoom={1}
maxZoom={18}
subdomains='abcd'
/>
<TileLayer
attribution='Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> &mdash; Map data &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://stamen-tiles-{s}.a.ssl.fastly.net/toner-labels/{z}/{x}/{y}.png"
minZoom={1}
subdomains='abcd'
maxZoom={18}
/>
</LayerGroup>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={esriName} checked={selectedLayer === esriName}>
<TileLayer
attribution='Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> &mdash; Map data &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://stamen-tiles-{s}.a.ssl.fastly.net/toner-labels/{z}/{x}/{y}.png"
minZoom={1}
subdomains='abcd'
maxZoom={18}
attribution="Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
/>
</LayerGroup>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={esriName} checked={selectedLayer === esriName}>
<TileLayer
attribution="Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
/>
</LayersControl.BaseLayer>
</LayersControl.BaseLayer>
</LayersControl>
</LayersControl>
{/* Add the markers for the universities */}
<UnivMarkers />
{/* Add the markers for the universities */}
<UnivMarkers/>
</Map>
</Map>
</>
);
}
}
......
import React from "react";
import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles";
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";
import Filter from "../filter/Filter";
/**
......@@ -17,18 +17,16 @@ import {withErrorBoundary} from "../common/ErrorBoundary";
*/
class PageMap extends React.Component {
render() {
const { theme } = this.props;
const {theme, classes} = this.props;
return (
<Paper style={theme.myPaper}>
<Grid container spacing={24}>
<Grid item xs={11}>
<Typography variant="h4" gutterBottom>
Exploration Cartographique
</Typography>
</Grid>
</Grid>
{/* <Filter /> */}
<UnivMap />
<Typography variant="h4" gutterBottom>
Exploration Cartographique
</Typography>
<div className={classes.filter}>
<Filter/>
</div>
<UnivMap/>
</Paper>
);
}
......@@ -36,11 +34,16 @@ class PageMap extends React.Component {
PageMap.propTypes = {
theme: PropTypes.object.isRequired,
classes: PropTypes.object.isRequired,
};
const styles = {};
const