import React from "react";
import Paper from "@material-ui/core/Paper";
import Tabs from "@material-ui/core/Tabs";
import Tab from "@material-ui/core/Tab";
import Typography from "@material-ui/core/Typography";
import Box from "@material-ui/core/Box";
import LinearProgress from "@material-ui/core/LinearProgress";
import axios from "axios";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import nextId from "react-id-generator";
import * as actions from "../../store/actions";
import "./RoutingMenu.css";
import VALID_MOTS from "../../constants";
import findMotIcon from "../../utils";
import SearchResults from "../SearchResults";
import SearchField from "../SearchField";
const _ = require("lodash/core");
function TabPanel(props) {
const { children, value, index } = props;
return (
<Typography
component="div"
role="tabpanel"
hidden={value !== index}
id={nextId()}
aria-labelledby={`simple-tab-${index}`}
>
{value === index && <Box p={3}>{children}</Box>}
</Typography>
);
}
/**
* The routing menu props
* @typedef RoutingMenuProps
* @type {props}
* @property {string} stationSearchUrl The station search API used for searching.
* @property {string} APIKey key obtained from geOps that enables you to used the previous API services.
* @property {string[]} mots List of mots to be available (ex: ['bus', 'train'])
* @property {LongLat} clickLocation The location clicked by the user in the form of [long,lat].
* @property {function} onShowNotification A store action that can be dispatched, takes the notification message and type as arguments.
* @property {function} onSetCurrentStopsGeoJSON A store action that can be dispatched, sets the current stops in the store in the form of GeoJSON format.
* @property {function} onSetCurrentMot TA store action that can be dispatched, sets the current selected mot by the user inside the store.
* @category Props
*/
/**
* The routing menu that controls station search
* @category RoutingMenu
*/
class RoutingMenu extends React.Component {
/**
* Default constructor, gets called automatically upon initialization.
* @param {...RoutingMenuProps} props Props received so that the component can function properly.
* @category RoutingMenu
*/
constructor(props) {
const { mots, onSetCurrentMot } = props;
super(props);
const currentMots = this.validateMots(mots);
this.state = {
currentMots,
currentMot: currentMots[0].name,
currentSearchResults: [],
focusedFieldIndex: 0,
currentStops: ["", ""],
currentStopsGeoJSON: {},
showLoadingBar: false
};
this.SearchCancelToken = axios.CancelToken;
this.searchCancel = null;
onSetCurrentMot(currentMots[0].name);
}
/**
* If a location was received through the props (user click on map) act accordingly.
* @category RoutingMenu
*/
componentDidUpdate(prevProps) {
const { clickLocation, onSetCurrentStopsGeoJSON } = this.props;
const { currentStops, focusedFieldIndex, currentStopsGeoJSON } = this.state;
if (clickLocation && clickLocation !== prevProps.clickLocation) {
const updatedCurrentStops = currentStops;
const updatedFocusedFieldIndex =
focusedFieldIndex + 1 < currentStops.length
? focusedFieldIndex + 1
: focusedFieldIndex;
updatedCurrentStops[focusedFieldIndex] = clickLocation;
const updatedCurrentStopsGeoJSON = _.clone(currentStopsGeoJSON);
// Create GeoJSON
const tempGeoJSON = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {
id: clickLocation.slice().reverse(),
type: "coordinates"
},
geometry: {
type: "Point",
coordinates: clickLocation
}
}
]
};
updatedCurrentStopsGeoJSON[focusedFieldIndex] = tempGeoJSON;
this.updateCurrentStops(
updatedCurrentStops,
updatedCurrentStopsGeoJSON,
updatedFocusedFieldIndex
);
onSetCurrentStopsGeoJSON(updatedCurrentStopsGeoJSON);
}
}
/**
* Update the current stops array (string array) and the GeoJSON array in the local state.
* @param updatedCurrentStops The updated stops.
* @param updatedCurrentStopsGeoJSON The updated GeoJSON.
* @category RoutingMenu
*/
updateCurrentStops = (
updatedCurrentStops,
updatedCurrentStopsGeoJSON,
updatedFocusedFieldIndex
) => {
this.setState({
currentStops: updatedCurrentStops,
currentStopsGeoJSON: updatedCurrentStopsGeoJSON,
focusedFieldIndex: updatedFocusedFieldIndex
});
};
/**
* Validate the mots provided from the props, then retrieve the icons for the valid ones.
* @param mots The provided mots
* @returns {Array} The valid mots with their icons
* @category RoutingMenu
*/
validateMots = mots => {
const currentMots = [];
mots.forEach(providedMot => {
const requestedMot = VALID_MOTS.find(mot => mot === providedMot);
if (requestedMot) {
currentMots.push({
name: requestedMot,
icon: findMotIcon(requestedMot)
});
}
});
if (currentMots.length === 0) {
currentMots.push({
name: VALID_MOTS[0],
icon: findMotIcon(VALID_MOTS[0])
});
}
return currentMots;
};
/**
* Process changing the current selected mot, save in local state and dispatch store action.
* @param event The change event
* @param newMot The new selected mot
* @category RoutingMenu
*/
handleMotChange = (event, newMot) => {
const { onSetCurrentMot } = this.props;
this.setState({ currentMot: newMot });
onSetCurrentMot(newMot);
};
/**
* Gets callled when a search field is in focus. Keep track of the last focused/selected field.
* @param fieldIndex The search field index(order)
* @category RoutingMenu
*/
onFieldFocusHandler = fieldIndex => {
this.setState({ focusedFieldIndex: fieldIndex });
};
/**
* Create a new search field (hop) between already existing search fields
* @param indexToInsertAt The index to insert the new search field at.
* @category RoutingMenu
*/
addNewSearchFieldHandler = indexToInsertAt => {
const { currentStops } = this.state;
const updatedCurrentStops = currentStops;
updatedCurrentStops.splice(indexToInsertAt, 0, "");
this.setState({ currentStops: updatedCurrentStops });
};
/**
* Remove a search field (hop) from a defined index. Then dispatch an update to the stops,
* so that the route can be updated if exists.
* @param indexToRemoveFrom The index to remove the search field from.
* @category RoutingMenu
*/
removeSearchFieldHandler = indexToRemoveFrom => {
const { currentStops, currentStopsGeoJSON } = this.state;
const { onSetCurrentStopsGeoJSON } = this.props;
const updatedCurrentStops = currentStops;
updatedCurrentStops.splice(indexToRemoveFrom, 1);
const updatedCurrentStopsGeoJSON = {};
Object.keys(currentStopsGeoJSON).forEach(key => {
if (key !== indexToRemoveFrom.toString()) {
updatedCurrentStopsGeoJSON[key] = currentStopsGeoJSON[key];
}
});
this.setState({
currentStops: updatedCurrentStops,
currentStopsGeoJSON: updatedCurrentStopsGeoJSON
});
onSetCurrentStopsGeoJSON(updatedCurrentStopsGeoJSON);
};
/**
* Perform searching for stations through the station API
* @param event
* @param fieldIndex The search field(hop) index(order)
* @category RoutingMenu
*/
searchStopsHandler = (event, fieldIndex) => {
const { currentStops, currentMot } = this.state;
const { stationSearchUrl, APIKey, onShowNotification } = this.props;
// only search if text is available
if (!event.target.value) {
const updateCurrentStops = currentStops;
updateCurrentStops[fieldIndex] = "";
this.setState({
currentSearchResults: [],
currentStops: updateCurrentStops,
showLoadingBar: false
});
return;
}
const updateCurrentStops = currentStops;
updateCurrentStops[fieldIndex] = event.target.value;
this.setState({
currentStops: updateCurrentStops,
showLoadingBar: true
});
if (this.searchCancel) {
// If a previous search request has been issues and not completed yet, cancel it.
this.searchCancel();
}
axios
.get(stationSearchUrl, {
params: {
q: event.target.value,
key: APIKey
},
cancelToken: new this.SearchCancelToken(cancel => {
this.searchCancel = cancel;
})
})
.then(
response => {
if (response.data.features.length === 0) {
// No results for the given query
onShowNotification("Couldn't find stations", "warning");
}
const searchResults = [];
response.data.features.forEach(singleResult => {
// Search results from the API
if (singleResult.properties.mot[currentMot])
searchResults.push(singleResult);
});
this.setState({
currentSearchResults: searchResults,
showLoadingBar: false
});
},
error => {
this.setState({
showLoadingBar: false
});
if (!axios.isCancel(error) || error)
onShowNotification("Error while searching for stations", "error");
}
);
};
/**
* The user makes changes to the current search. Either select the first result,
* or delete the text to make a new search.
* @param event
* @category RoutingMenu
*/
processHighlightedResultSelectHandler = event => {
const { onSetCurrentStopsGeoJSON } = this.props;
const {
currentSearchResults,
currentStops,
focusedFieldIndex,
currentStopsGeoJSON
} = this.state;
const [firstSearchResult] = currentSearchResults;
if (event.key === "Enter" && firstSearchResult) {
// The user has chosen the first result by pressing 'Enter' key on keyboard
const updateCurrentStops = currentStops;
updateCurrentStops[focusedFieldIndex] = firstSearchResult.properties.name;
const updatedCurrentStopsGeoJSON = _.clone(currentStopsGeoJSON);
updatedCurrentStopsGeoJSON[focusedFieldIndex] = firstSearchResult;
this.setState({
currentStops: updateCurrentStops,
currentSearchResults: [],
currentStopsGeoJSON: updatedCurrentStopsGeoJSON
});
onSetCurrentStopsGeoJSON(updatedCurrentStopsGeoJSON);
}
if (event.key === "Backspace") {
// The user has erased some of the search query. Reset everything and start all over.
let updateCurrentSearchResults = [];
if (event.target.value) updateCurrentSearchResults = currentSearchResults;
const updatedCurrentStopsGeoJSON = {};
Object.keys(currentStopsGeoJSON).forEach(key => {
if (key !== focusedFieldIndex.toString()) {
updatedCurrentStopsGeoJSON[key] = currentStopsGeoJSON[key];
}
});
this.setState({
currentStopsGeoJSON: updatedCurrentStopsGeoJSON,
currentSearchResults: updateCurrentSearchResults
});
onSetCurrentStopsGeoJSON(updatedCurrentStopsGeoJSON);
}
};
/**
* The user uses the mouse/touch to select one of the search results.
* @param searchResult The clicked search result.
* @category RoutingMenu
*/
processClickedResultHandler = searchResult => {
const { currentStops, focusedFieldIndex, currentStopsGeoJSON } = this.state;
const { onSetCurrentStopsGeoJSON } = this.props;
const updateCurrentStops = currentStops;
updateCurrentStops[focusedFieldIndex] = searchResult.properties.name;
const updatedCurrentStopsGeoJSON = _.clone(currentStopsGeoJSON);
updatedCurrentStopsGeoJSON[focusedFieldIndex] = searchResult;
this.setState({
currentStops: updateCurrentStops,
currentSearchResults: [],
currentStopsGeoJSON: updatedCurrentStopsGeoJSON
});
onSetCurrentStopsGeoJSON(updatedCurrentStopsGeoJSON);
};
/**
* Render the component to the dom.
* @category RoutingMenu
*/
render() {
const {
currentStops,
currentMots,
currentMot,
showLoadingBar,
currentSearchResults
} = this.state;
return (
<div className="RoutingMenu">
<Paper square elevation={3}>
<Tabs
value={currentMot}
onChange={this.handleMotChange}
variant="scrollable"
scrollButtons="auto"
indicatorColor="primary"
textColor="primary"
aria-label="mots icons"
>
{currentMots.map(singleMot => {
return (
<Tab
key={`mot-${singleMot.name}`}
value={singleMot.name}
icon={singleMot.icon}
aria-label={singleMot.name}
/>
);
})}
</Tabs>
<TabPanel>
{currentStops.map((singleStop, index) => {
return(<SearchField
key={`searchField-${index}`} index={index} addNewSearchFieldHandler={this.addNewSearchFieldHandler}
currentStops={currentStops} removeSearchFieldHandler={this.removeSearchFieldHandler}
searchStopsHandler={this.searchStopsHandler} singleStop={singleStop}
processHighlightedResultSelectHandler={this.processHighlightedResultSelectHandler}
onFieldFocusHandler={this.onFieldFocusHandler}
/>);
})}
</TabPanel>
{showLoadingBar ? <LinearProgress /> : null}
</Paper>
<SearchResults
currentSearchResults={currentSearchResults}
processClickedResultHandler={this.processClickedResultHandler}
/>
</div>
);
}
}
const mapStateToProps = state => {
return {
clickLocation: state.MapReducer.clickLocation
};
};
const mapDispatchToProps = dispatch => {
return {
onSetCurrentMot: currentMot => dispatch(actions.setCurrentMot(currentMot)),
onSetCurrentStopsGeoJSON: currentStopsGeoJSON =>
dispatch(actions.setCurrentStopsGeoJSON(currentStopsGeoJSON)),
onSetClickLocation: clickLocation =>
dispatch(actions.setClickLocation(clickLocation)),
onShowNotification: (notificationMessage, notificationType) =>
dispatch(actions.showNotification(notificationMessage, notificationType))
};
};
TabPanel.propTypes = {
children: PropTypes.node.isRequired,
value: PropTypes.string,
index: PropTypes.number
};
TabPanel.defaultProps = {
value: null,
index: null
};
RoutingMenu.propTypes = {
onSetCurrentMot: PropTypes.func.isRequired,
onSetCurrentStopsGeoJSON: PropTypes.func.isRequired,
onShowNotification: PropTypes.func.isRequired,
clickLocation: PropTypes.arrayOf(PropTypes.number),
mots: PropTypes.arrayOf(PropTypes.string).isRequired,
APIKey: PropTypes.string.isRequired,
stationSearchUrl: PropTypes.string.isRequired
};
RoutingMenu.defaultProps = {
clickLocation: null
};
export default connect(mapStateToProps, mapDispatchToProps)(RoutingMenu);
Source