From 58a2c20a878d9ed6ecbb37dd743bc6787ff55e7a Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Wed, 5 Jul 2017 19:15:31 -0400 Subject: [PATCH 1/5] Create LocationForm component and Redux functions, which search for a user's location using an address or HTML5 geolocation. --- api/controllers/GeolocationController.js | 28 +++++ api/controllers/SurveyController.js | 2 +- api/controllers/index.js | 1 + config/routes.js | 5 + frontend/js/components/atoms/Hr.js | 25 ++++ .../js/components/ecosystems/LocationForm.js | 84 +++++++++++++ frontend/js/components/environments/Home.js | 15 ++- .../components/environments/SurveySelector.js | 35 ++++++ frontend/js/components/organisms/TextField.js | 45 +++++++ .../js/redux/actions/pick-survey-actions.js | 113 +++++++++++++++++- .../js/redux/reducers/pick-survey-reducer.js | 58 ++++++++- frontend/js/routes.js | 6 +- frontend/styles/alert.scss | 1 + frontend/styles/hr.scss | 24 ++++ frontend/styles/location-form.scss | 13 ++ frontend/styles/style.scss | 5 +- frontend/styles/text-field.scss | 33 +++++ package.json | 1 + .../controllers/GeolocationController.test.js | 9 ++ 19 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 api/controllers/GeolocationController.js create mode 100644 frontend/js/components/atoms/Hr.js create mode 100644 frontend/js/components/ecosystems/LocationForm.js create mode 100644 frontend/js/components/environments/SurveySelector.js create mode 100644 frontend/js/components/organisms/TextField.js create mode 100644 frontend/styles/hr.scss create mode 100644 frontend/styles/location-form.scss create mode 100644 frontend/styles/text-field.scss create mode 100644 test/integration/controllers/GeolocationController.test.js diff --git a/api/controllers/GeolocationController.js b/api/controllers/GeolocationController.js new file mode 100644 index 0000000..13a7074 --- /dev/null +++ b/api/controllers/GeolocationController.js @@ -0,0 +1,28 @@ +'use strict'; + +const Controller = require('trails-controller'); +const fetch = require('isomorphic-fetch'); +const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY; +const geocodingUrl = 'https://maps.googleapis.com/maps/api/geocode/json'; + +/** + * @module GeolocationController + * @description A controller for handling Geolocation requests. + */ +module.exports = class GeolocationController extends Controller { + + get(request, reply) { + const address = JSON.stringify(request.query.address); + + fetch(`${geocodingUrl}?address=${address}&key=${GOOGLE_API_KEY}`) + .then((response) => response.json()) + .then((response) => { + reply(response); + }) + .catch((error) => { + reply(error); + }); + } + +}; + diff --git a/api/controllers/SurveyController.js b/api/controllers/SurveyController.js index 240e6ac..697e192 100644 --- a/api/controllers/SurveyController.js +++ b/api/controllers/SurveyController.js @@ -11,7 +11,7 @@ module.exports = class SurveyController extends Controller { getSurveyByLatLng(request, reply) { - const coordinates = request.query.coordinates; + const coordinates = JSON.parse(request.query.coordinates); this.app.services.SurveyService.getSurveyByLatLng(coordinates.latitude, coordinates.longitude) diff --git a/api/controllers/index.js b/api/controllers/index.js index 62cdbac..30419e1 100644 --- a/api/controllers/index.js +++ b/api/controllers/index.js @@ -8,3 +8,4 @@ exports.CategoryController = require('./CategoryController'); exports.QuestionController = require('./QuestionController'); exports.SurveyResultController = require('./SurveyResultController'); exports.SurveyController = require('./SurveyController'); +exports.GeolocationController = require('./GeolocationController') diff --git a/config/routes.js b/config/routes.js index f340622..feacc65 100644 --- a/config/routes.js +++ b/config/routes.js @@ -111,5 +111,10 @@ module.exports = [ method: ['GET'], path: '/api/v1/default/info', handler: 'DefaultController.info' + }, + { + method: ['GET'], + path: '/api/v1/geolocation', + handler: 'GeolocationController.get' } ]; diff --git a/frontend/js/components/atoms/Hr.js b/frontend/js/components/atoms/Hr.js new file mode 100644 index 0000000..ae0af2e --- /dev/null +++ b/frontend/js/components/atoms/Hr.js @@ -0,0 +1,25 @@ +'use strict'; + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +class Hr extends Component { + render() { + return ( +
+
+ { + this.props.label && + + } +
+ ); + } +} + +Hr.propTypes = { + label: PropTypes.string, + color: PropTypes.string +}; + +export default Hr; diff --git a/frontend/js/components/ecosystems/LocationForm.js b/frontend/js/components/ecosystems/LocationForm.js new file mode 100644 index 0000000..62437c8 --- /dev/null +++ b/frontend/js/components/ecosystems/LocationForm.js @@ -0,0 +1,84 @@ +'use strict'; + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import Hr from './../atoms/hr'; +import Alert from './../organisms/Alert'; +import TextField from './../organisms/TextField'; + +import { + getLocationByGPS, + getLocationByAddress +} from './../../redux/actions/pick-survey-actions'; + +class LocationForm extends Component { + constructor(props) { + super(props); + this.state = {address: ''}; + } + + updateAddress(event) { + const address = event.target.value; + this.setState({ + address: address + }); + } + + search(event) { + event && event.preventDefault(); + this.props.dispatch(getLocationByAddress(this.state.address), () => { + this.setState({address: ''}); + }); + } + + geolocate() { + this.props.dispatch(getLocationByGPS()); + } + + render() { + return ( +
+
+ {this.props.status.message && + + } +
+
+ + Search + + }/> +
+
+
+ + +
+
+ ); + } +} + +LocationForm.propTypes = { + dispatch: PropTypes.func, + addressError: PropTypes.string, + status: PropTypes.object +}; + +export default LocationForm; diff --git a/frontend/js/components/environments/Home.js b/frontend/js/components/environments/Home.js index b528f9b..053faf0 100644 --- a/frontend/js/components/environments/Home.js +++ b/frontend/js/components/environments/Home.js @@ -1,7 +1,10 @@ -import React, { PropTypes, Component } from 'react'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; import { connect } from 'react-redux'; + import Card from './../atoms/Card'; +import LocationForm from './../ecosystems/LocationForm'; class Home extends Component { @@ -15,6 +18,10 @@ class Home extends Component {
OKCandidate Home Screen
+
); @@ -24,12 +31,14 @@ class Home extends Component { } Home.propTypes = { - user: PropTypes.object + user: PropTypes.object, + pickSurvey: PropTypes.object, + dispatch: PropTypes.func }; export default connect( state => ({ - survey: state.survey, + pickSurvey: state.pickSurvey, ui: state.ui }) )(Home); diff --git a/frontend/js/components/environments/SurveySelector.js b/frontend/js/components/environments/SurveySelector.js new file mode 100644 index 0000000..7731baa --- /dev/null +++ b/frontend/js/components/environments/SurveySelector.js @@ -0,0 +1,35 @@ +'use strict'; + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { connect } from 'react-redux'; + +import Card from './../atoms/Card'; + +class SurveySelector extends Component { + constructor(props) { + super(props); + } + + render() { + return ( +
+ +
Survey Selector
+
+
+ ); + } +} + +SurveySelector.propTypes = { + surveys: PropTypes.array +}; + +export default connect( + state => ({ + pickSurvey: state.pickSurvey, + ui: state.ui + }) +)(SurveySelector); diff --git a/frontend/js/components/organisms/TextField.js b/frontend/js/components/organisms/TextField.js new file mode 100644 index 0000000..1d327fe --- /dev/null +++ b/frontend/js/components/organisms/TextField.js @@ -0,0 +1,45 @@ +'use strict'; + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +class TextField extends Component { + render() { + return ( +
+ { + this.props.label && + + } +
+ + { this.props.button } +
+ { + this.props.error && +
+ {this.props.error} +
+ } +
+ ); + } +} + +TextField.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string, + label: PropTypes.string, + error: PropTypes.string, + onChange: PropTypes.func, + type: PropTypes.string, + button: PropTypes.element +}; + +export default TextField; diff --git a/frontend/js/redux/actions/pick-survey-actions.js b/frontend/js/redux/actions/pick-survey-actions.js index 088a47e..4509866 100644 --- a/frontend/js/redux/actions/pick-survey-actions.js +++ b/frontend/js/redux/actions/pick-survey-actions.js @@ -28,7 +28,7 @@ export function fetchSurveysByLocationFailure(error) { export function fetchSurveysByLocation(coordinates) { return function(dispatch) { dispatch(fetchSurveysByLocationRequest()); - return fetch('/api/v1/survey/location', {coordinates: coordinates}) + return fetch(`/api/v1/survey/location?coordinates=${JSON.stringify(coordinates)}`) .then(checkStatus) .then(response => response.json()) .then(response => { @@ -37,3 +37,114 @@ export function fetchSurveysByLocation(coordinates) { .catch(error => dispatch(fetchSurveysByLocationFailure(error))); }; } + +export const GET_LOCATION_BY_ADDRESS_REQUEST = 'GET_LOCATION_BY_ADDRESS_REQUEST'; +export const GET_LOCATION_BY_ADDRESS_SUCCESS = 'GET_LOCATION_BY_ADDRESS_SUCCESS'; +export const GET_LOCATION_BY_ADDRESS_FAILURE = 'GET_LOCATION_BY_ADDRESS_FAILURE'; + +export function getLocationByAddress(address) { + return function(dispatch) { + dispatch(getLocationByAddressRequest()); + fetch(`/api/v1/geolocation?address=${address}`) + .then(checkStatus) + .then((response) => response.json()) + .then((response) => { + // If there are no results + if (!response.results.length) { + return dispatch(getLocationByAddressFailure( + new Error('No results found') + )); + } + // If there are multiple results + else if (response.results.length > 1) { + // TODO + // Address disambiguation page + } + // If there is one result + const coords = response.results[0].geometry.location; + dispatch(getLocationByAddressSuccess(coords)); + return dispatch(fetchSurveysByLocation({ + latitude: coords.lat, + longitude: coords.lng + })); + }) + .catch((error) => { + return fetchSurveysByLocationFailure(error); + }); + }; +} + +export function getLocationByAddressRequest() { + return { + type: GET_LOCATION_BY_ADDRESS_REQUEST + }; +} + +export function getLocationByAddressSuccess(response) { + return { + type: GET_LOCATION_BY_ADDRESS_SUCCESS, + response + }; +} + +export function getLocationByAddressFailure(error) { + return { + type: GET_LOCATION_BY_ADDRESS_FAILURE, + error + }; +} + +export const GET_LOCATION_BY_GPS_REQUEST = 'GET_LOCATION_BY_GPS_REQUEST'; +export const GET_LOCATION_BY_GPS_SUCCESS = 'GET_LOCATION_BY_GPS_SUCCESS'; +export const GET_LOCATION_BY_GPS_FAILURE = 'GET_LOCATION_BY_GPS_FAILURE'; + +function onGeolocationSuccess(dispatch, timer, position) { + clearTimeout(timer); + dispatch(getLocationByGPSSuccess(position)); + dispatch(fetchSurveysByLocation({ + latitude: position.coords.latitude, + longitude: position.coords.longitude + })); +} + +function onGeolocationFailure(dispatch, timer, positionError) { + clearTimeout(timer); + dispatch(getLocationByGPSFailure(positionError)); +} + +export function getLocationByGPS() { + return function(dispatch) { + dispatch(getLocationByGPSRequest()); + if (!navigator.geolocation) { + dispatch(getLocationByGPSFailure(new Error('No support for geolocation'))); + } + + const timer = setTimeout(() => { + dispatch(getLocationByGPSFailure(new Error('Request timed out'))); + }, 10000); + navigator.geolocation.getCurrentPosition( + onGeolocationSuccess.bind(this, dispatch, timer), + onGeolocationFailure.bind(this, dispatch, timer) + ); + }; +} + +export function getLocationByGPSRequest() { + return { + type: GET_LOCATION_BY_GPS_REQUEST + }; +} + +export function getLocationByGPSSuccess(response) { + return { + type: GET_LOCATION_BY_GPS_SUCCESS, + response + }; +} + +export function getLocationByGPSFailure(error) { + return { + type: GET_LOCATION_BY_GPS_FAILURE, + error + }; +} diff --git a/frontend/js/redux/reducers/pick-survey-reducer.js b/frontend/js/redux/reducers/pick-survey-reducer.js index c76a903..08a390e 100644 --- a/frontend/js/redux/reducers/pick-survey-reducer.js +++ b/frontend/js/redux/reducers/pick-survey-reducer.js @@ -1,13 +1,20 @@ import { FETCH_SURVEYS_BY_LOCATION_REQUEST, FETCH_SURVEYS_BY_LOCATION_SUCCESS, - FETCH_SURVEYS_BY_LOCATION_FAILURE + FETCH_SURVEYS_BY_LOCATION_FAILURE, + GET_LOCATION_BY_ADDRESS_REQUEST, + GET_LOCATION_BY_ADDRESS_SUCCESS, + GET_LOCATION_BY_ADDRESS_FAILURE, + GET_LOCATION_BY_GPS_REQUEST, + GET_LOCATION_BY_GPS_SUCCESS, + GET_LOCATION_BY_GPS_FAILURE } from './../actions/pick-survey-actions'; const initialState = { isFetching: false, surveys: [], - error: '' + error: '', + status: {} }; export default function surveyReducer(state = initialState, action) { @@ -29,6 +36,53 @@ export default function surveyReducer(state = initialState, action) { isFetching: false, error: action.error }); + case GET_LOCATION_BY_ADDRESS_REQUEST: + return Object.assign({}, state, { + isFetching: true, + status: { + message: 'Trying to find you using an address', + status: 'info' + } + }); + case GET_LOCATION_BY_ADDRESS_SUCCESS: + return Object.assign({}, state, { + isFetching: true, + status: { + message: 'Looking for surveys for that address', + status: 'info' + } + }); + case GET_LOCATION_BY_ADDRESS_FAILURE: + return Object.assign({}, state, { + isFetching: true, + status: { + message: 'There was an error locating you with that address', + status: 'danger' + } + }); + case GET_LOCATION_BY_GPS_REQUEST: + return Object.assign({}, state, { + isFetching: true, + status: { + message: 'Trying to find you using GPS', + status: 'info' + } + }); + case GET_LOCATION_BY_GPS_SUCCESS: + return Object.assign({}, state, { + isFetching: false, + status: { + message: 'Looking for surveys in your area', + level: 'info' + } + }); + case GET_LOCATION_BY_GPS_FAILURE: + return Object.assign({}, state, { + status: { + message: 'There was an error finding you with GPS.\nTry searching with an address?', + level: 'danger' + } + }); default: return state; } diff --git a/frontend/js/routes.js b/frontend/js/routes.js index 23c9f52..1561b13 100644 --- a/frontend/js/routes.js +++ b/frontend/js/routes.js @@ -4,6 +4,7 @@ import { Route, IndexRoute } from 'react-router'; import Frame from './components/Frame'; import Home from './components/environments/Home'; import Admin from './components/environments/Admin'; +import SurveySelector from './components/environments/SurveySelector'; import Category from './components/environments/Category'; import Survey from './components/environments/Survey'; import SurveyCreator from './components/environments/SurveyCreator'; @@ -18,8 +19,9 @@ import Results from './components/environments/Results'; module.exports = ( - - + + + diff --git a/frontend/styles/alert.scss b/frontend/styles/alert.scss index 3feef04..0f4f5e7 100644 --- a/frontend/styles/alert.scss +++ b/frontend/styles/alert.scss @@ -6,6 +6,7 @@ align-items: center; padding: .5em 1em; border-radius: 4px; + margin-bottom: 1em; &.info { background: $blue; diff --git a/frontend/styles/hr.scss b/frontend/styles/hr.scss new file mode 100644 index 0000000..3b1c6ea --- /dev/null +++ b/frontend/styles/hr.scss @@ -0,0 +1,24 @@ +@import 'colors'; + +.hr { + text-align: center; + position: relative; + z-index: 0; + + .hr-line { + width: 100%; + height: 1px; + position: absolute; + top: 50%; + background: $black; + z-index: -1; + } + + .hr-label { + text-transform: uppercase; + color: $black; + display: inline; + padding: 0 1em; + background: $white; + } +} \ No newline at end of file diff --git a/frontend/styles/location-form.scss b/frontend/styles/location-form.scss new file mode 100644 index 0000000..471f890 --- /dev/null +++ b/frontend/styles/location-form.scss @@ -0,0 +1,13 @@ +.location-form { + text-align: center; + + .by-address { + #address { + + } + } + + .by-gps { + + } +} \ No newline at end of file diff --git a/frontend/styles/style.scss b/frontend/styles/style.scss index 6f431bb..2eff3a1 100644 --- a/frontend/styles/style.scss +++ b/frontend/styles/style.scss @@ -18,4 +18,7 @@ This file exists only to import other stylesheets. @import 'survey-card'; @import 'results'; @import 'agreement-selector'; -@import 'alert'; \ No newline at end of file +@import 'alert'; +@import 'hr'; +@import 'location-form'; +@import 'text-field'; \ No newline at end of file diff --git a/frontend/styles/text-field.scss b/frontend/styles/text-field.scss new file mode 100644 index 0000000..3edad45 --- /dev/null +++ b/frontend/styles/text-field.scss @@ -0,0 +1,33 @@ +@import 'colors'; + +.text-field { + text-align: left; + input { + margin-bottom: 16px; + } + + .text-field-input { + display: flex; + + input { + flex: 1; + } + + &.has-button { + input { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + } + + .text-field-error { + color: $red; + position: relative; + bottom: 12px; + } +} \ No newline at end of file diff --git a/package.json b/package.json index 779a2b5..91c05d4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "inert": "^4.2.0", "isomorphic-fetch": "^2.2.1", "pg": "^6.1.0", + "prop-types": "^15.5.10", "react": "^15.3.2", "react-burger-menu": "^1.10.9", "react-dnd": "^2.4.0", diff --git a/test/integration/controllers/GeolocationController.test.js b/test/integration/controllers/GeolocationController.test.js new file mode 100644 index 0000000..75a73fb --- /dev/null +++ b/test/integration/controllers/GeolocationController.test.js @@ -0,0 +1,9 @@ +'use strict' + +const assert = require('assert') + +describe('GeolocationController', () => { + it('should exist', () => { + assert(global.app.controllers.GeolocationController) + }) +}) From f9e6de18eef4c7d208e61f71af6c2b0d3d7eabf4 Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Wed, 5 Jul 2017 19:23:18 -0400 Subject: [PATCH 2/5] Fix style violations and failing geolocation test. --- api/controllers/index.js | 2 +- .../controllers/GeolocationController.test.js | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/api/controllers/index.js b/api/controllers/index.js index 30419e1..ab14092 100644 --- a/api/controllers/index.js +++ b/api/controllers/index.js @@ -8,4 +8,4 @@ exports.CategoryController = require('./CategoryController'); exports.QuestionController = require('./QuestionController'); exports.SurveyResultController = require('./SurveyResultController'); exports.SurveyController = require('./SurveyController'); -exports.GeolocationController = require('./GeolocationController') +exports.GeolocationController = require('./GeolocationController'); diff --git a/test/integration/controllers/GeolocationController.test.js b/test/integration/controllers/GeolocationController.test.js index 75a73fb..0f6b3b8 100644 --- a/test/integration/controllers/GeolocationController.test.js +++ b/test/integration/controllers/GeolocationController.test.js @@ -1,9 +1,10 @@ -'use strict' +'use strict'; -const assert = require('assert') +const assert = require('assert'); +const app = require('../../../index'); describe('GeolocationController', () => { - it('should exist', () => { - assert(global.app.controllers.GeolocationController) - }) -}) + it('should exist', () => { + assert(app.api.controllers.GeolocationController); + }); +}); From f8a167ce5dafb941157062f952b18142153ff84d Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Wed, 5 Jul 2017 19:25:33 -0400 Subject: [PATCH 3/5] add example google api key to env example. --- .env-example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env-example b/.env-example index 993be9e..dda4e45 100644 --- a/.env-example +++ b/.env-example @@ -10,3 +10,4 @@ OKC_DB_USER=blaine OKC_DB_PASS=complicatedPassword OKC_SESSION_SECRET_KEY=someGobbledygookThatIsAtLeast32CharactersLong +GOOGLE_API_KEY=google_api_key \ No newline at end of file From 5c3f5854f8cde573e582f371bf48a497269dfccb Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Fri, 7 Jul 2017 10:49:07 -0400 Subject: [PATCH 4/5] Wire up background prop for Hr component. --- frontend/js/components/atoms/Hr.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/js/components/atoms/Hr.js b/frontend/js/components/atoms/Hr.js index ae0af2e..a2016dc 100644 --- a/frontend/js/components/atoms/Hr.js +++ b/frontend/js/components/atoms/Hr.js @@ -5,9 +5,13 @@ import PropTypes from 'prop-types'; class Hr extends Component { render() { + const hrLineStyle = { + background: this.props.background + }; + return (
-
+
{ this.props.label && @@ -19,7 +23,7 @@ class Hr extends Component { Hr.propTypes = { label: PropTypes.string, - color: PropTypes.string + background: PropTypes.string }; export default Hr; From 0b164cb6f7bfae2f4f6132b21d3b038d59693aff Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Fri, 7 Jul 2017 11:08:41 -0400 Subject: [PATCH 5/5] Address review comments. --- frontend/js/components/ecosystems/LocationForm.js | 2 +- frontend/js/components/environments/Home.js | 1 - frontend/js/redux/reducers/pick-survey-reducer.js | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/js/components/ecosystems/LocationForm.js b/frontend/js/components/ecosystems/LocationForm.js index 62437c8..38122c6 100644 --- a/frontend/js/components/ecosystems/LocationForm.js +++ b/frontend/js/components/ecosystems/LocationForm.js @@ -50,7 +50,7 @@ class LocationForm extends Component {