diff --git a/config/webpack.config.prod.js b/config/webpack.config.prod.js index f63d0f6d05637a81ad015c92b22c3db630029168..0eb878fac2adeac89ac2653e839dfe7ae96d9771 100644 --- a/config/webpack.config.prod.js +++ b/config/webpack.config.prod.js @@ -135,7 +135,7 @@ module.exports = { test: /\.scss$/, loader: ExtractTextPlugin.extract( 'style', - 'css?modules&importLoaders=1&-autoprefixer!postcss!resolve-url!sass' + 'css?importLoaders=1!postcss!sass' ) }, // JSON is not enabled by default in Webpack but both Node and Browserify diff --git a/package.json b/package.json index db99d7e53b78b7a5278452c44ebbc926f0ed24b0..e7a62217c17665ca333eeda568c64cf576ba81ea 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "whatwg-fetch": "1.0.0" }, "dependencies": { + "bpl": "git+https://7f8bbb4b0a383ad905fee5b0f7cbc0c22533b556:x-oauth-basic@github.com/Blocp/bpl.git", "react": "^15.3.2", "react-dom": "^15.3.2", "react-redux": "^4.4.5", @@ -62,8 +63,8 @@ "react-router-redux": "^4.0.7", "redux": "^3.6.0", "redux-promise": "^0.5.3", - "whatwg-fetch": "^1.0.0", - "bpl": "git+https://7f8bbb4b0a383ad905fee5b0f7cbc0c22533b556:x-oauth-basic@github.com/Blocp/bpl.git" + "redux-saga": "^0.14.2", + "whatwg-fetch": "^1.0.0" }, "scripts": { "start": "node scripts/start.js", diff --git a/src/components/BuildingListTable/index.js b/src/components/BuildingListTable/index.js index 8e2adfc4d4e4d777d9b2b4fd85838aa881d7a608..275586a57bb33be90305b7f800dd98be87ff72a1 100644 --- a/src/components/BuildingListTable/index.js +++ b/src/components/BuildingListTable/index.js @@ -1,6 +1,6 @@ import React, { PropTypes } from 'react'; import { Link } from 'react-router'; -import './styles.css'; +import './styles.scss'; export default function BuildingListTable({ buildings }) { if (!buildings || buildings.length === 0) { diff --git a/src/components/BuildingListTable/styles.css b/src/components/BuildingListTable/styles.scss similarity index 100% rename from src/components/BuildingListTable/styles.css rename to src/components/BuildingListTable/styles.scss diff --git a/src/components/BuildingOverview/index.js b/src/components/BuildingOverview/index.js index f07ec448da96a4cf0c9f62c90820789780d6fbf8..ee12d28f37ab02c5914cd55652649f6797fadcb4 100644 --- a/src/components/BuildingOverview/index.js +++ b/src/components/BuildingOverview/index.js @@ -1,11 +1,7 @@ import React, { PropTypes } from 'react'; +import './styles.scss'; export default function BuildingOverview({ building }) { - if (Object.keys(building).length === 0) { - // TODO add loading icon? - return
...
; - } - return (

diff --git a/src/components/BuildingOverview/styles.css b/src/components/BuildingOverview/styles.scss similarity index 100% rename from src/components/BuildingOverview/styles.css rename to src/components/BuildingOverview/styles.scss diff --git a/src/components/SideBarDetail/index.js b/src/components/SideBarDetail/index.js new file mode 100644 index 0000000000000000000000000000000000000000..fb62883ea388069d22fa287c021c4634b11a6f16 --- /dev/null +++ b/src/components/SideBarDetail/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import './styles.scss'; + +export default function SideBarDetail() { + return ( +
+

Sidebar

+
+ ); +} diff --git a/src/containers/BuildingDetail/styles.css b/src/components/SideBarDetail/styles.scss similarity index 100% rename from src/containers/BuildingDetail/styles.css rename to src/components/SideBarDetail/styles.scss diff --git a/src/components/TurkHit/defaultForm.js b/src/components/TurkHit/defaultForm.js new file mode 100644 index 0000000000000000000000000000000000000000..b8fa47c87c48c8304d0a37a71cce5bffbbe46256 --- /dev/null +++ b/src/components/TurkHit/defaultForm.js @@ -0,0 +1,16 @@ +// blocpower_id and address will be added from component +const defaultTurkHit = { + instructions_text: 'We need to get building dimensions for buildings using Google Earth. Read the instructions below on what measurements need to be taken.', + instructions_url: 'http://beta.blocpower.us/dimensions/BuildingDimensionsInstructions.pdf', + worksheet_url: 'http://beta.blocpower.us/dimensions/BuildingDimensionsTemplate.xlsx', + max_assignments: '1', + title: 'Measure building dimensions', + description: 'Use google earth to measure building\'s dimensions, windows, and doors', + keywords: 'building, dimensions, google earth', + duration: '300', + reward: '5', + MIN_FILE_BYTES: 1, + MAX_FILE_BYTES: 10, +}; + +export default defaultTurkHit; diff --git a/src/components/TurkHit/index.js b/src/components/TurkHit/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e549d09d36d2b580afac77ce83548a1344c9b273 --- /dev/null +++ b/src/components/TurkHit/index.js @@ -0,0 +1,45 @@ +import React, { PropTypes } from 'react'; +import defaultForm from './defaultForm'; +import './styles.scss'; + +export default function TurkHit({ createHit, address, blocpower_id, hit }) { + let hitStatus =
; + if (!hit.error.message && hit.status !== '') { + hitStatus = ( +
+

HIT Id: {hit.id}

+

HIT Status: {hit.status}

+
+ ); + } else { + hitStatus = ( +
+

{hit.error.message}

+
+ ); + } + + return ( +
+ + {hitStatus} +
+ ); +} + +TurkHit.propTypes = { + createHit: PropTypes.func, + address: PropTypes.string, + blocpower_id: PropTypes.number, + hit: PropTypes.shape({ + id: PropTypes.string, + status: PropTypes.string, + loading: PropTypes.boolean, + error: PropTypes.boolean, + }), +}; diff --git a/src/components/TurkHit/styles.scss b/src/components/TurkHit/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/containers/BuildingDetail/actions.js b/src/containers/BuildingDetail/actions.js index e31836f2e28ba5c6b227162885cf70f4f3376e15..356c341109ee1bbf3f0e08918911efc0c633941a 100644 --- a/src/containers/BuildingDetail/actions.js +++ b/src/containers/BuildingDetail/actions.js @@ -1,25 +1,93 @@ import 'whatwg-fetch'; -import { FETCH_BUILDING_DETAIL } from './constants'; +import { + LOAD_BUILDING_DETAIL, + LOAD_BUILDING_DETAIL_SUCCEES, + LOAD_BUILDING_DETAIL_ERROR, + CREATE_HIT, + CREATE_HIT_SUCCEES, + CREATE_HIT_ERROR, +} from './constants'; -const ROOT_URL = `${process.env.REACT_APP_BUILDING_SERVICE}/building/`; -const HEADERS = new Headers({ 'x-blocpower-app-key': process.env.REACT_APP_KEY }); +/** + * Load building details, this action starts the request saga + * + * @param blocPowerID The current blocPowerID + * @returns {object} An action object with a type of LOAD_BUILDING_DETAIL + * passing the building detail + */ +export function loadBuildingDetail(blocPowerID) { + return { + type: LOAD_BUILDING_DETAIL, + payload: blocPowerID, + }; +} -const init = { - method: 'GET', - headers: HEADERS, - mode: 'cors', - cache: 'default', -}; +/** + * Dispatched when the building details are loaded by the request saga + * + * @param {object} buildingDetail The building data + * @returns {object} An action object with a type of + * LOAD_BUILDING_DETAIL_SUCCEES + */ +export function buildingDetailLoaded(buildingDetail) { + return { + type: LOAD_BUILDING_DETAIL_SUCCEES, + payload: buildingDetail, + }; +} -function fetchBuildingDetail(blocPowerID) { +/** + * Dispatched when loading the building detail fails + * + * @param {object} error The error + * + * @return {object} An action object with a type of + * LOAD_BUILDING_DETAIL_ERROR passing the error + */ +export function buildingDetailLoadingError(error) { return { - type: FETCH_BUILDING_DETAIL, - payload: fetch(`${ROOT_URL}${blocPowerID}`, init).then(response => - response.json() - ), + type: LOAD_BUILDING_DETAIL_ERROR, + error, }; } -/* eslint-disable import/prefer-default-export */ -export { fetchBuildingDetail }; -/* eslint-enable */ +/** + * Create mturk HIT, this action starts the request saga + * + * @param {object} formData The mturk form data + * @returns {object} An action object with a type of CREATE_HIT + */ +export function createHit(formData) { + return { + type: CREATE_HIT, + payload: formData, + }; +} + +/** + * Dispatched when the mturk HIT was successfully created + * + * @param {object} hitData The created hit data + * @returns {object} An action object with a type of CREATE_HIT_SUCCEES + */ +export function createHitSuccess(hitData) { + return { + type: CREATE_HIT_SUCCEES, + payload: hitData, + }; +} + +/** + * Dispatched when creating the mturk HIT fails + * + * @param {object} error The error + * + * @return {object} An action object with a type of CREATE_HIT_ERROR + * passing the error + */ +export function createHitError(error) { + return { + type: CREATE_HIT_ERROR, + error, + }; +} diff --git a/src/containers/BuildingDetail/constants.js b/src/containers/BuildingDetail/constants.js index 1d22cced45a63a673f33fa0c59af1268e4305d08..873b9080d77454308cb96f389e1c36077d58b7c8 100644 --- a/src/containers/BuildingDetail/constants.js +++ b/src/containers/BuildingDetail/constants.js @@ -1,3 +1,6 @@ -/* eslint-disable import/prefer-default-export */ -export const FETCH_BUILDING_DETAIL = 'FETCH_BUILDING_DETAIL'; -/* eslint-enable */ +export const LOAD_BUILDING_DETAIL = 'LOAD_BUILDING_DETAIL'; +export const LOAD_BUILDING_DETAIL_SUCCEES = 'LOAD_BUILDING_DETAIL_SUCCEES'; +export const LOAD_BUILDING_DETAIL_ERROR = 'LOAD_BUILDING_DETAIL_ERROR'; +export const CREATE_HIT = 'CREATE_HIT'; +export const CREATE_HIT_SUCCEES = 'CREATE_HIT_SUCCEES'; +export const CREATE_HIT_ERROR = 'CREATE_HIT_ERROR'; diff --git a/src/containers/BuildingDetail/index.js b/src/containers/BuildingDetail/index.js index 1339a137870f454ab7c333962cd9db75a7c76fb3..d8789ee3e335cac29379c66d09d107569f1b5818 100644 --- a/src/containers/BuildingDetail/index.js +++ b/src/containers/BuildingDetail/index.js @@ -1,34 +1,87 @@ import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { loadBuildingDetail, createHit } from './actions'; +import './styles.scss'; +import SideBarDetail from '../../components/SideBarDetail'; +import TurkHit from '../../components/TurkHit'; -import { fetchBuildingDetail } from './actions'; -import './styles.css'; -import BuildingOverview from '../../components/BuildingOverview'; class BuildingDetail extends Component { componentDidMount() { - this.props.fetchBuildingDetail(this.props.params.blocPowerID); + this.props.loadBuildingDetail(this.props.params.blocPowerID); } render() { - return ; + let mainContent =
; + let hit =
; + + if (this.props.buildingDetail.overview.error) { + mainContent = ( +

+ {this.props.buildingDetail.overview.error.message} +

+ ); + } else { + mainContent = React.cloneElement(this.props.children, { + building: this.props.buildingDetail.overview, + }); + + hit = ( + + ); + } + + return ( +
+
+ +
+
+ {mainContent} + {hit} +
+
+ ); } } BuildingDetail.propTypes = { buildingDetail: PropTypes.shape({ - address: PropTypes.string, - bbl: PropTypes.number, - blocpower_id: PropTypes.number, - borough: PropTypes.string, - zipcode: PropTypes.number, + overview: PropTypes.shape({ + address: PropTypes.string, + bbl: PropTypes.number, + blocpower_id: PropTypes.number, + borough: PropTypes.string, + zipcode: PropTypes.number, + loading: PropTypes.boolean, + error: PropTypes.boolean, + }), + hit: PropTypes.shape({ + id: PropTypes.string, + status: PropTypes.string, + loading: PropTypes.boolean, + error: PropTypes.boolean, + }), }), params: PropTypes.objectOf(PropTypes.string), - fetchBuildingDetail: PropTypes.func, + loadBuildingDetail: PropTypes.func, + createHit: PropTypes.func, + children: React.PropTypes.element, }; +function mapDispatchToProps(dispatch) { + return bindActionCreators({ loadBuildingDetail, createHit }, dispatch); +} + function mapStateToProps({ buildingDetail }) { return { buildingDetail }; } -export default connect(mapStateToProps, { fetchBuildingDetail })(BuildingDetail); +export default connect(mapStateToProps, mapDispatchToProps)(BuildingDetail); diff --git a/src/containers/BuildingDetail/reducer.js b/src/containers/BuildingDetail/reducer.js index a49aa9c3e76794afdf24c61b3f372ab5beb96ca5..457c6ed739417d42b920af95be052ddff1bdb7b4 100644 --- a/src/containers/BuildingDetail/reducer.js +++ b/src/containers/BuildingDetail/reducer.js @@ -1,9 +1,87 @@ -import { FETCH_BUILDING_DETAIL } from './constants'; +import { + LOAD_BUILDING_DETAIL, + LOAD_BUILDING_DETAIL_SUCCEES, + LOAD_BUILDING_DETAIL_ERROR, + CREATE_HIT, + CREATE_HIT_SUCCEES, + CREATE_HIT_ERROR, +} from './constants'; -export default function (state = {}, action) { +const initState = { + overview: { + loading: false, + error: false, + }, + hit: { + id: '', + status: '', + loading: false, + error: false, + }, +}; + +export default function (state = initState, action) { switch (action.type) { - case FETCH_BUILDING_DETAIL: - return action.payload.data; + case LOAD_BUILDING_DETAIL: + return { + ...state, + overview: { + ...state.overview, + loading: true, + error: false, + }, + }; + + case LOAD_BUILDING_DETAIL_SUCCEES: + return { + ...state, + overview: { + ...state.overview, + ...action.payload.data, + loading: false, + }, + }; + + case LOAD_BUILDING_DETAIL_ERROR: + return { + ...state, + overview: { + ...state.overview, + loading: true, + error: action.error, + }, + }; + + case CREATE_HIT: + return { + ...state, + hit: { + ...state.hit, + loading: true, + error: false, + }, + }; + + case CREATE_HIT_SUCCEES: + return { + ...state, + hit: { + ...state.hit, + id: action.payload.data.hit_id, + status: 'Submitted', + loading: false, + }, + }; + + case CREATE_HIT_ERROR: + return { + ...state, + hit: { + ...state.hit, + loading: false, + error: action.error, + }, + }; default: return state; diff --git a/src/containers/BuildingDetail/sagas.js b/src/containers/BuildingDetail/sagas.js new file mode 100644 index 0000000000000000000000000000000000000000..319b938a11d3e9726622bc22fca5aaeac2f45a31 --- /dev/null +++ b/src/containers/BuildingDetail/sagas.js @@ -0,0 +1,72 @@ +import { call, put, takeEvery } from 'redux-saga/effects'; +import request from '../../utils/request'; + +import { + LOAD_BUILDING_DETAIL, + CREATE_HIT, +} from './constants'; + +import { + buildingDetailLoaded, + buildingDetailLoadingError, + createHitSuccess, + createHitError, +} from './actions'; + +const BUILDING_SERVICE_URL = `${process.env.REACT_APP_BUILDING_SERVICE}`; +const HEADERS = new Headers({ + 'x-blocpower-app-key': process.env.REACT_APP_KEY, +}); + +/** + * Detail page request/response handler + * + * @param {object} action blocPowerId of building + */ +function* getBuildingDetail(action) { + const blocPowerId = action.payload; + const data = yield call( + request, + `${BUILDING_SERVICE_URL}/building/${blocPowerId}`, { + method: 'GET', + headers: HEADERS, + } + ); + + if (!data.err) { + yield put(buildingDetailLoaded(data)); + } else { + yield put(buildingDetailLoadingError(data.err)); + } +} + +/** + * Mechanical Turk HIT creation request/response handler + * + * @param {object} action Form data of mechanical turk job + */ +function* createHit(action) { + const turkHitFormData = action.payload; + const data = yield call(request, `${BUILDING_SERVICE_URL}/turkhit/`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(turkHitFormData), + }); + + if (!data.err) { + yield put(createHitSuccess(data)); + } else { + yield put(createHitError(data.err)); + } +} + +/** + * Watches for LOAD_BUILDING_DETAIL & CREATE_HIT actions and calls + * the appropriate handler function. + */ +function* buildingDetailWatcher() { + yield takeEvery(LOAD_BUILDING_DETAIL, getBuildingDetail); + yield takeEvery(CREATE_HIT, createHit); +} + +export default buildingDetailWatcher; diff --git a/src/containers/BuildingDetail/styles.scss b/src/containers/BuildingDetail/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/containers/BuildingList/index.js b/src/containers/BuildingList/index.js index a06bf3c16044b56d4f4ce03cdd850582d44ed8bf..3e775c91a0cb3d502c8e8cc1f3c25f7e892d490a 100644 --- a/src/containers/BuildingList/index.js +++ b/src/containers/BuildingList/index.js @@ -1,11 +1,10 @@ - import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { fetchBuildings, searchTerm } from './actions'; import BuildingListTable from '../../components/BuildingListTable'; -import './styles.css'; +import './styles.scss'; import { SearchSVG } from '../../components/bpl'; // TODO remove this address diff --git a/src/containers/BuildingList/reducer.js b/src/containers/BuildingList/reducer.js index 6c804205b1a62101965efb48e8b8e2c9695de06d..13acd7e523fec1da413e9c3b9d1c88e9f2602256 100644 --- a/src/containers/BuildingList/reducer.js +++ b/src/containers/BuildingList/reducer.js @@ -3,7 +3,7 @@ import { FETCH_BUILDINGS, SEARCH_TERM } from './constants'; export default function (state = {}, action) { switch (action.type) { case FETCH_BUILDINGS: - return { ...state, buildings: action.payload.data }; + return { ...state, buildings: action.payload.buildings }; case SEARCH_TERM: return { ...state, term: action.payload }; diff --git a/src/containers/BuildingList/styles.css b/src/containers/BuildingList/styles.scss similarity index 100% rename from src/containers/BuildingList/styles.css rename to src/containers/BuildingList/styles.scss diff --git a/src/reducer.js b/src/reducers.js similarity index 83% rename from src/reducer.js rename to src/reducers.js index af7f5c9bc2e0136f0d58a670692dad81c301be83..3568c2e97cbcec614ecf80980febf3020f658e45 100644 --- a/src/reducer.js +++ b/src/reducers.js @@ -4,10 +4,8 @@ import { routerReducer } from 'react-router-redux'; import BuildingListReducer from './containers/BuildingList/reducer'; import BuildingDetailReducer from './containers/BuildingDetail/reducer'; -const rootReducer = combineReducers({ +export default combineReducers({ routing: routerReducer, buildingList: BuildingListReducer, buildingDetail: BuildingDetailReducer, }); - -export default rootReducer; diff --git a/src/routes.js b/src/routes.js index 281d3477251de40f9bf4ba2b338efe6626371810..24075922b7870f7f5c4d6cb70c3df84afcde0029 100644 --- a/src/routes.js +++ b/src/routes.js @@ -2,13 +2,16 @@ import React from 'react'; import { Route, IndexRoute, IndexRedirect } from 'react-router'; import BuildingList from './containers/BuildingList'; import BuildingDetail from './containers/BuildingDetail'; +import BuildingOverview from './components/BuildingOverview'; -module.exports = ( +export default ( - + + + ); diff --git a/src/sagas.js b/src/sagas.js new file mode 100644 index 0000000000000000000000000000000000000000..644286f84cd58aca32892992442cbcdb1562dde0 --- /dev/null +++ b/src/sagas.js @@ -0,0 +1,7 @@ +import buildingDetailSaga from './containers/BuildingDetail/sagas'; + +export default function* rootSaga() { + yield [ + buildingDetailSaga(), + ]; +} diff --git a/src/store.js b/src/store.js index d397ae7334ebba491d3df136b4c1f77eba69606b..83daf1d4ddd0e9eeb25c01875956ebdabcedf56a 100644 --- a/src/store.js +++ b/src/store.js @@ -2,7 +2,10 @@ import { createStore, applyMiddleware, compose } from 'redux'; import ReduxPromise from 'redux-promise'; import { browserHistory } from 'react-router'; import { syncHistoryWithStore } from 'react-router-redux'; -import rootReducer from './reducer'; +import createSagaMiddleware from 'redux-saga'; + +import rootReducer from './reducers'; +import rootSaga from './sagas'; /* eslint-disable no-underscore-dangle */ const composeEnhancers = @@ -12,15 +15,18 @@ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ // Specify here name, actionsBlacklist, actionsCreators and other options }) : compose; +/* eslint-enable */ + +const sagaMiddleware = createSagaMiddleware(); const enhancer = composeEnhancers( - applyMiddleware(ReduxPromise), + applyMiddleware(sagaMiddleware, ReduxPromise), // other store enhancers if any ); -/* eslint-enable */ const store = createStore(rootReducer, enhancer); - const history = syncHistoryWithStore(browserHistory, store); +sagaMiddleware.run(rootSaga); + export { store, history }; diff --git a/src/utils/request.js b/src/utils/request.js new file mode 100644 index 0000000000000000000000000000000000000000..fae139a806cc767f217d043fe43d63f267d10ed2 --- /dev/null +++ b/src/utils/request.js @@ -0,0 +1,35 @@ +import 'whatwg-fetch'; + + +/** + * Checks if a network request came back fine, and throws an error if not + * + * @param {object} response A response from a network request + * + * @return {object|undefined} Returns either the response, or throws an error + */ +function checkStatus(response) { + if (response.ok) { + return response; + } + + const error = new Error(response.statusText); + error.response = response; + throw error; +} + +/** + * Requests a URL, returning a promise + * + * @param {string} url The URL we want to request + * @param {object} [options] The options we want to pass to "fetch" + * + * @return {object} An object containing either "data" or "err" + */ +export default function request(url, options) { + return fetch(url, options) + .then(checkStatus) + .then(response => response.json()) + .then(data => ({ data })) + .catch(err => ({ err })); +}