From 57f98ad39884fcf82f7acda20309bc26d2e642f8 Mon Sep 17 00:00:00 2001 From: John Lyon-Smith Date: Fri, 6 Apr 2018 14:59:18 -0700 Subject: [PATCH] Debug issues with work item CRUD --- .prettierrc | 3 + mobile/package-lock.json | 5 + mobile/package.json | 3 +- mobile/src/API.js | 288 +++++++++++++----------- mobile/src/Auth/DefaultRoute.js | 6 +- mobile/src/Home/Home.js | 236 +++++++++++++------ mobile/src/WorkItem/WorkItem.js | 114 +++++----- mobile/src/WorkItem/WorkItemList.js | 135 ++++------- mobile/src/ui/BoundInput.js | 11 +- mobile/src/ui/BoundOptionStrip.js | 36 +-- mobile/src/ui/BoundText.js | 68 ++++++ mobile/src/ui/OptionStrip.js | 77 +++++-- mobile/src/util.js | 46 ++++ server/src/api/routes/WorkItemRoutes.js | 74 +++--- 14 files changed, 684 insertions(+), 418 deletions(-) create mode 100644 mobile/src/ui/BoundText.js create mode 100644 mobile/src/util.js diff --git a/.prettierrc b/.prettierrc index d8b7432..e164c1a 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,6 @@ { "semi": false, + "arrowParens": "always", + "trailingComma": "es5", + "jsxBracketSameLine": true, } \ No newline at end of file diff --git a/mobile/package-lock.json b/mobile/package-lock.json index b23b6f8..08453df 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -7038,6 +7038,11 @@ "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=" }, + "url-search-params-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-3.0.0.tgz", + "integrity": "sha512-oRNWuBkJ/zKKK1aiBaTBZTf07zOKd0g+nJYB+vFNPO14gFjA75BaHgIJLtveWBRxI/2qff7xcTb9H6wkpTmqjg==" + }, "use": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/use/-/use-3.1.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index d0752c7..dec32cf 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -32,6 +32,7 @@ "react-native-swipe-list-view": "^1.0.7", "react-router-native": "^4.2.0", "react-viro": "^2.4.0", - "socket.io-client": "^2.0.4" + "socket.io-client": "^2.0.4", + "url-search-params-polyfill": "^3.0.0" } } diff --git a/mobile/src/API.js b/mobile/src/API.js index 7107cbc..4696879 100644 --- a/mobile/src/API.js +++ b/mobile/src/API.js @@ -1,42 +1,42 @@ -import EventEmitter from 'eventemitter3' -import io from 'socket.io-client' -import { AsyncStorage } from 'react-native' +import EventEmitter from "eventemitter3" +import io from "socket.io-client" +import { AsyncStorage } from "react-native" -const authTokenName = 'AuthToken' +const authTokenName = "AuthToken" let baseURL = null let apiPath = null // if (__DEV__) { const localIPAddr = process.env.LOCAL_IP_ADDR - baseURL = `http://${localIPAddr || 'localhost'}:3001` - apiPath = '' + baseURL = `http://${localIPAddr || "localhost"}:3001` + apiPath = "" } else { - baseURL = 'https://dar.kss.us.com' - apiPath = '/api' + baseURL = "https://dar.kss.us.com" + apiPath = "/api" } class NetworkError extends Error { constructor(message) { super(message) this.name = this.constructor.name - if (typeof Error.captureStackTrace === 'function') { + if (typeof Error.captureStackTrace === "function") { Error.captureStackTrace(this, this.constructor) } else { - this.stack = (new Error(message)).stack + this.stack = new Error(message).stack } } } class APIError extends Error { constructor(status, message) { - super(message || '') + super(message || "") this.status = status || 500 this.name = this.constructor.name - if (typeof Error.captureStackTrace === 'function') { + if (typeof Error.captureStackTrace === "function") { Error.captureStackTrace(this, this.constructor) } else { - this.stack = (new Error(message)).stack + this.stack = new Error(message).stack } } } @@ -46,39 +46,42 @@ class API extends EventEmitter { super() this.user = { pending: true } - AsyncStorage.getItem(authTokenName).then((token) => { - if (!token) { - return Promise.reject() - } + AsyncStorage.getItem(authTokenName) + .then((token) => { + if (!token) { + return Promise.reject() + } - this.token = token - return this.who() - }).then((user) => { - this.user = user - this.connectSocket() - this.emit('login') - }).catch((err) => { - console.error(err) - AsyncStorage.removeItem(authTokenName) - this.token = null - this.user = {} - this.socket = null - this.emit('logout') - }) + this.token = token + return this.who() + }) + .then((user) => { + this.user = user + this.connectSocket() + this.emit("login") + }) + .catch((err) => { + console.error(err) + AsyncStorage.removeItem(authTokenName) + this.token = null + this.user = {} + this.socket = null + this.emit("logout") + }) } connectSocket() { this.socket = io(baseURL, { - path: apiPath + '/socketio', + path: apiPath + "/socketio", query: { - auth_token: this.token - } + auth_token: this.token, + }, }) - this.socket.on('disconnect', (reason) => { + this.socket.on("disconnect", (reason) => { // Could happen if the auth_token is bad this.socket = null }) - this.socket.on('notify', (message) => { + this.socket.on("notify", (message) => { const { eventName, eventData } = message // Filter the few massages that affect our cached user data to avoid a server round trip @@ -109,22 +112,27 @@ class API extends EventEmitter { makeImageUrl(id, size) { if (id) { - return apiPath + '/assets/' + id + '?access_token=' + this.token + return apiPath + "/assets/" + id + "?access_token=" + this.token } else if (size && size.width && size.height) { - return `${apiPath}/placeholders/${size.width}x${size.height}?access_token=${this.token}` + return `${apiPath}/placeholders/${size.width}x${ + size.height + }?access_token=${this.token}` } else { return null } } makeAssetUrl(id) { - return id ? apiPath + '/assets/' + id + '?access_token=' + this.token : null + return id ? apiPath + "/assets/" + id + "?access_token=" + this.token : null } static makeParams(params) { - return params ? '?' + Object.keys(params).map((key) => ( - [key, params[key]].map(encodeURIComponent).join('=') - )).join('&') : '' + return params + ? "?" + + Object.keys(params) + .map((key) => [key, params[key]].map(encodeURIComponent).join("=")) + .join("&") + : "" } request(method, path, requestBody, requestOptions) { @@ -132,80 +140,88 @@ class API extends EventEmitter { var promise = new Promise((resolve, reject) => { let fetchOptions = { method: method, - mode: 'cors', - cache: 'no-store' + mode: "cors", + cache: "no-store", } let headers = new Headers() if (this.token) { - headers.set('Authorization', 'Bearer ' + this.token) + headers.set("Authorization", "Bearer " + this.token) } - if (method === 'POST' || method === 'PUT') { + if (method === "POST" || method === "PUT") { if (requestOptions.binary) { - headers.set('Content-Type', 'application/octet-stream') - headers.set('Content-Length', requestOptions.binary.length) - headers.set('Range', 'byte ' + requestOptions.binary.offset) + headers.set("Content-Type", "application/octet-stream") + headers.set("Content-Length", requestOptions.binary.length) + headers.set("Range", "byte " + requestOptions.binary.offset) fetchOptions.body = requestBody } else { - headers.set('Content-Type', 'application/json') + headers.set("Content-Type", "application/json") fetchOptions.body = JSON.stringify(requestBody) } } fetchOptions.headers = headers - fetch(this.apiURL + path, fetchOptions).then((res) => { - return Promise.all([ Promise.resolve(res), (requestOptions.binary && method === 'GET') ? res.blob() : res.json() ]) - }).then((arr) => { - let [ res, responseBody ] = arr - if (res.ok) { - if (requestOptions.wantHeaders) { - resolve({ body: responseBody, headers: res.headers }) + fetch(this.apiURL + path, fetchOptions) + .then((res) => { + return Promise.all([ + Promise.resolve(res), + requestOptions.binary && method === "GET" ? res.blob() : res.json(), + ]) + }) + .then((arr) => { + let [res, responseBody] = arr + if (res.ok) { + if (requestOptions.wantHeaders) { + resolve({ body: responseBody, headers: res.headers }) + } else { + resolve(responseBody) + } } else { - resolve(responseBody) + reject(new APIError(res.status, responseBody.message)) } - } else { - reject(new APIError(res.status, responseBody.message)) - } - }).catch((error) => { - reject(new NetworkError(error.message)) - }) + }) + .catch((error) => { + reject(new NetworkError(error.message)) + }) }) return promise } post(path, requestBody, options) { - return this.request('POST', path, requestBody, options) + return this.request("POST", path, requestBody, options) } put(path, requestBody, options) { - return this.request('PUT', path, requestBody, options) + return this.request("PUT", path, requestBody, options) } get(path, options) { - return this.request('GET', path, options) + return this.request("GET", path, options) } delete(path, options) { - return this.request('DELETE', path, options) + return this.request("DELETE", path, options) } login(email, password, remember) { return new Promise((resolve, reject) => { - this.post('/auth/login', { email, password }, { wantHeaders: true }).then((response) => { - // Save bearer token for later use - const authValue = response.headers.get('Authorization') - const [ scheme, token ] = authValue.split(' ') + this.post("/auth/login", { email, password }, { wantHeaders: true }) + .then((response) => { + // Save bearer token for later use + const authValue = response.headers.get("Authorization") + const [scheme, token] = authValue.split(" ") - if (scheme !== 'Bearer' || !token) { - reject(new APIError('Unexpected Authorization scheme or token')) - } + if (scheme !== "Bearer" || !token) { + reject(new APIError("Unexpected Authorization scheme or token")) + } - if (remember) { - AsyncStorage.setItem(authTokenName, token) - } - this.token = token - this.user = response.body - this.connectSocket() - this.emit('login') - resolve(response.body) - }).catch((err) => { - reject(err) - }) + if (remember) { + AsyncStorage.setItem(authTokenName, token) + } + this.token = token + this.user = response.body + this.connectSocket() + this.emit("login") + resolve(response.body) + }) + .catch((err) => { + reject(err) + }) }) } logout() { @@ -215,61 +231,63 @@ class API extends EventEmitter { this.token = null this.user = {} this.disconnectSocket() - this.emit('logout') + this.emit("logout") } - return this.delete('/auth/login').then(cb, cb) + return this.delete("/auth/login").then(cb, cb) } who() { - return this.get('/auth/who') + return this.get("/auth/who") } getUser(_id) { - return this.get('/users/' + _id) + return this.get("/users/" + _id) } listUsers() { - return this.get('/users') + return this.get("/users") } createUser(user) { - return this.post('/users', user) + return this.post("/users", user) } updateUser(user) { return new Promise((resolve, reject) => { - this.put('/users', user).then((user) => { - // If we just updated ourselves, update the internal cached copy - if (user._id === this.user._id) { - this.user = user - this.emit('login') - } - resolve(user) - }).catch((reason) => { - reject(reason) - }) + this.put("/users", user) + .then((user) => { + // If we just updated ourselves, update the internal cached copy + if (user._id === this.user._id) { + this.user = user + this.emit("login") + } + resolve(user) + }) + .catch((reason) => { + reject(reason) + }) }) } deleteUser(_id) { - return this.delete('/users/' + _id) + return this.delete("/users/" + _id) } enterRoom(roomName) { - return this.put('/users/enter-room/' + (roomName || '')) + return this.put("/users/enter-room/" + (roomName || "")) } leaveRoom() { - return this.put('/users/leave-room') + return this.put("/users/leave-room") } getWorkItem(_id) { - return this.get('/workitems/' + _id) + return this.get("/workitems/" + _id) } listWorkItems() { - return this.get('/workitems') + return this.get("/workitems") } createWorkItem(workItem) { - return this.post('/workitems', workItem) + return this.post("/workitems", workItem) } updateWorkItem(workItem) { - return this.put('/workitems', workItem) + return this.put("/workitems", workItem) } deleteWorkItem(_id) { - return this.delete('/workitems/' + _id) + return this.delete("/workitems/" + _id) } upload(file, progressCallback) { @@ -285,36 +303,40 @@ class API extends EventEmitter { const buffer = e.target.result const bytesRead = buffer.byteLength - this.post('/assets/upload/' + uploadId, buffer, { - binary: { offset: chunk * chunkSize, length: bytesRead } - }).then((uploadData) => { - chunk++ - if (!progressCallback(uploadData)) { - return Promise.reject(new Error('Upload was canceled')) - } - if (chunk < numberOfChunks) { - let start = chunk * chunkSize - let end = Math.min(fileSize, start + chunkSize) - reader.readAsArrayBuffer(file.slice(start, end)) - } else { - resolve(uploadData) - } - }).catch((err) => { - reject(err) + this.post("/assets/upload/" + uploadId, buffer, { + binary: { offset: chunk * chunkSize, length: bytesRead }, }) + .then((uploadData) => { + chunk++ + if (!progressCallback(uploadData)) { + return Promise.reject(new Error("Upload was canceled")) + } + if (chunk < numberOfChunks) { + let start = chunk * chunkSize + let end = Math.min(fileSize, start + chunkSize) + reader.readAsArrayBuffer(file.slice(start, end)) + } else { + resolve(uploadData) + } + }) + .catch((err) => { + reject(err) + }) } - this.post('/assets/upload', { + this.post("/assets/upload", { fileName: file.name, fileSize, contentType: file.type, - numberOfChunks - }).then((uploadData) => { - uploadId = uploadData.uploadId - reader.readAsArrayBuffer(file.slice(0, chunkSize)) - }).catch((err) => { - reject(err) + numberOfChunks, }) + .then((uploadData) => { + uploadId = uploadData.uploadId + reader.readAsArrayBuffer(file.slice(0, chunkSize)) + }) + .catch((err) => { + reject(err) + }) }) } } diff --git a/mobile/src/Auth/DefaultRoute.js b/mobile/src/Auth/DefaultRoute.js index f915466..1d41d5d 100644 --- a/mobile/src/Auth/DefaultRoute.js +++ b/mobile/src/Auth/DefaultRoute.js @@ -1,7 +1,7 @@ -import React, { Fragment, Component } from 'react' -import { Route, Redirect } from 'react-router-native' +import React, { Fragment, Component } from "react" +import { Route, Redirect } from "react-router-native" export const DefaultRoute = () => { // NOTE: When working on the app, change this to the page you are working on - return ()} /> + return } /> } diff --git a/mobile/src/Home/Home.js b/mobile/src/Home/Home.js index 3901cc4..6a82680 100644 --- a/mobile/src/Home/Home.js +++ b/mobile/src/Home/Home.js @@ -1,4 +1,4 @@ -import React from 'react' +import React from "react" import { StyleSheet, Text, @@ -7,32 +7,80 @@ import { Image, View, TouchableOpacity, -} from 'react-native' -import MapView, { Marker } from 'react-native-maps' -import { Icon, Header } from '../ui' -import { api } from '../API' -import autobind from 'autobind-decorator' -import pinImage from './images/pin.png' -import { ifIphoneX } from 'react-native-iphone-x-helper' +} from "react-native" +import MapView, { Marker } from "react-native-maps" +import { Icon, Header } from "../ui" +import { api } from "../API" +import autobind from "autobind-decorator" +import pinImage from "./images/pin.png" +import { ifIphoneX } from "react-native-iphone-x-helper" const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#FFFFFF', - alignItems: 'flex-start', - justifyContent: 'flex-start', - } + backgroundColor: "#FFFFFF", + alignItems: "flex-start", + justifyContent: "flex-start", + }, }) const data = [ - {key: '1', title: 'Remove Animal Carcass', location: 'Ossington Ave. | 0.2 mi.', state: 'planned', latlng: { latitude: 43.653226, longitude: -79.383184 } }, - {key: '2', title: 'Fix sign post', location: 'Alexandre St. | 0.7 mi.', state: 'open', latlng: { latitude: 43.648118, longitude: 79.392636 }}, - {key: '3', title: 'Overflowing trash', location: 'Bay St. | 0.8 mi.', state: 'open', latlng: { latitude: 43.640168, longitude: -79.409373 }}, - {key: '4', title: 'Leaking water pipe', location: 'Bloor St. | 1.2 mi.', state: 'planned', latlng: { latitude: 43.633110, longitude: -79.415880 }}, - {key: '5', title: 'Tree branch in road', location: 'Blue Jays Way | 2.2 mi.', state: 'open', latlng: { latitude: 43.653526, longitude: -79.361385 }}, - {key: '6', title: 'Washing machine on sidewalk', location: 'Christie St. | 3.0 mi.', state: 'open', latlng: { latitude: 43.663870, longitude: -79.383705 }}, - {key: '7', title: 'Dead moose', location: 'Cummer Ave. | 4.2 mi.', state: 'open', latlng: { latitude: 43.659166, longitude: -79.391350 }}, - {key: '8', title: 'Glass in street', location: 'Danforth Ave. | 4.7 mi.', state: 'open', latlng: { latitude: 43.663538, longitude: -79.423212 }}, + { + key: "1", + title: "Remove Animal Carcass", + location: "Ossington Ave. | 0.2 mi.", + state: "planned", + latlng: { latitude: 43.653226, longitude: -79.383184 }, + }, + { + key: "2", + title: "Fix sign post", + location: "Alexandre St. | 0.7 mi.", + state: "open", + latlng: { latitude: 43.648118, longitude: 79.392636 }, + }, + { + key: "3", + title: "Overflowing trash", + location: "Bay St. | 0.8 mi.", + state: "open", + latlng: { latitude: 43.640168, longitude: -79.409373 }, + }, + { + key: "4", + title: "Leaking water pipe", + location: "Bloor St. | 1.2 mi.", + state: "planned", + latlng: { latitude: 43.63311, longitude: -79.41588 }, + }, + { + key: "5", + title: "Tree branch in road", + location: "Blue Jays Way | 2.2 mi.", + state: "open", + latlng: { latitude: 43.653526, longitude: -79.361385 }, + }, + { + key: "6", + title: "Washing machine on sidewalk", + location: "Christie St. | 3.0 mi.", + state: "open", + latlng: { latitude: 43.66387, longitude: -79.383705 }, + }, + { + key: "7", + title: "Dead moose", + location: "Cummer Ave. | 4.2 mi.", + state: "open", + latlng: { latitude: 43.659166, longitude: -79.39135 }, + }, + { + key: "8", + title: "Glass in street", + location: "Danforth Ave. | 4.7 mi.", + state: "open", + latlng: { latitude: 43.663538, longitude: -79.423212 }, + }, ] export class Home extends React.Component { @@ -43,42 +91,45 @@ export class Home extends React.Component { @autobind _handleNavigatorEvent(event) { switch (event.id) { - case 'logout': + case "logout": api.logout().then(() => { - this.props.history.replace('/login') + this.props.history.replace("/login") }) break - case 'viewer': - this.props.push('/viewer') + case "viewer": + this.props.push("/viewer") break } } @autobind handleWorkItemsListPress() { - this.props.history.push('/workitemlist') + this.props.history.push("/workitemlist") } @autobind handleItemSelect(item, index) { - this.props.history.push('/activity') + this.props.history.push("/activity") } @autobind handleLogoutPress() { - this.props.history.replace('/logout') + this.props.history.replace("/logout") } @autobind handleGlassesPress() { - this.props.history.push('/arviewer') + this.props.history.push("/arviewer") } @autobind handleMyLocationPress() { navigator.geolocation.getCurrentPosition((info) => { if (this.map) { - this.map.animateToCoordinate({latitude: info.coords.latitude, longitude: info.coords.longitude}) + this.map.animateToCoordinate({ + latitude: info.coords.latitude, + longitude: info.coords.longitude, + }) } }) } @@ -87,13 +138,17 @@ export class Home extends React.Component { return (
+ title="Work Item Map" + leftButton={{ icon: "logout", onPress: this.handleLogoutPress }} + rightButton={{ icon: "glasses", onPress: this.handleGlassesPress }} + /> { this.map = ref }} + ref={(ref) => { + this.map = ref + }} style={{ - width: '100%', height: '50%', + width: "100%", + height: "50%", }} showsUserLocation showsBuildings={false} @@ -106,58 +161,97 @@ export class Home extends React.Component { latitudeDelta: 0.0922, longitudeDelta: 0.0922, }}> - { - data.map(marker => ( - - )) - } + {data.map((marker) => ( + + ))} - - - - + + + + { + renderItem={({ item, index }) => { return ( - - {item.state.toUpperCase()} - + + + {item.state.toUpperCase()} + + {item.title} - {item.location} + + {item.location} + - (this.handleItemSelect(item, index))} > - + this.handleItemSelect(item, index)}> + ) - }} /> - + }} + /> + - + - Hide List + Hide List - + - ); + ) } } diff --git a/mobile/src/WorkItem/WorkItem.js b/mobile/src/WorkItem/WorkItem.js index 8eedbe4..0a95200 100644 --- a/mobile/src/WorkItem/WorkItem.js +++ b/mobile/src/WorkItem/WorkItem.js @@ -8,7 +8,7 @@ import { TextInput, KeyboardAvoidingView, Platform, - TouchableOpacity + TouchableOpacity, } from "react-native" import MapView, { Marker } from "react-native-maps" import { FormBinder } from "react-form-binder" @@ -19,19 +19,21 @@ import { Icon, Header, PhotoButton, - BoundOptionStrip + BoundOptionStrip, } from "../ui" import { MessageModal } from "../Modal" import autobind from "autobind-decorator" import { ifIphoneX, isIphoneX } from "react-native-iphone-x-helper" import KeyboardSpacer from "react-native-keyboard-spacer" import { api } from "../API" +import "url-search-params-polyfill" +import { workItemTypeEnum, formatLatLng, parseLatLng } from "../util" const styles = StyleSheet.create({ container: { flexDirection: "column", flexGrow: 1, - backgroundColor: "#DDDDDD" + backgroundColor: "#DDDDDD", }, panel: { width: "94%", @@ -42,60 +44,68 @@ const styles = StyleSheet.create({ shadowOffset: { width: 2, height: 2 }, shadowRadius: 2, shadowOpacity: 0.5, - padding: 10 + padding: 10, }, label: { fontSize: 14, - marginBottom: 4 - } + marginBottom: 4, + }, }) -const workItemOptions = [ - { value: "order", text: "Work Order" }, - { value: "inspection", text: "Inspection" }, - { value: "complaint", text: "Complaint" } -] - -const latLngToString = (lat, lng) => - `${Math.abs(lng).toFixed(4)}°${lng > 0 ? "S" : "N"}, ${Math.abs(lat).toFixed( - 4 - )}°${lat > 0 ? "W" : "E"}` -const latLngStringToPoint = str => { - const parts = str.split(", ") - return { - type: "Point", - coordinates: [ - new Number(parts[0].substring(0, parts[0].length - 2)), - new Number(parts[1].substring(0, parts[1].length - 2)) - ] - } -} - export class WorkItem extends React.Component { static bindings = { header: { noValue: true, - isDisabled: r => !(r.anyModified && r.allValid) + isDisabled: (r) => !(r.anyModified && r.allValid), }, location: { isValid: true, - isDisabled: true + isDisabled: true, }, details: { - isValid: (r, v) => v !== "" + isValid: (r, v) => v !== "", }, workItemType: { isValid: true, - initValue: "order", - alwaysGet: true - } + alwaysGet: true, + }, } constructor(props) { super(props) this.state = { binder: new FormBinder({}, WorkItem.bindings), - messageModal: null + messageModal: null, + } + + const { search } = this.props.location + + if (search) { + const id = new URLSearchParams(search).get("id") + + if (id) { + api + .getWorkItem(id) + .then((workItem) => { + if (workItem) { + const [lng, lat] = workItem.location.coordinates + workItem.location = formatLatLng(lat, lng) + this.setState({ + binder: new FormBinder(workItem, WorkItem.bindings), + }) + } + }) + .catch((err) => { + this.setState({ + messageModal: { + icon: "hand", + message: "Unable to get work item details", + detail: err.message, + back: true, + }, + }) + }) + } } } @@ -115,36 +125,36 @@ export class WorkItem extends React.Component { const { binder } = this.state let obj = binder.getModifiedFieldValues() - obj.location = latLngStringToPoint(obj.location) + obj.location = parseLatLng(obj.location) if (!obj._id) { api .createWorkItem(obj) - .then(workItem => { + .then((workItem) => { this.handleBackPress() }) - .catch(error => { + .catch((error) => { this.setState({ messageModal: { icon: "hand", message: "Unable to create work item", - detail: error.message - } + detail: error.message, + }, }) }) } else { api .updateWorkItem(obj) - .then(workItem => { + .then((workItem) => { this.handleBackPress() }) - .catch(error => { + .catch((error) => { this.setState({ messageModal: { icon: "hand", message: "Unable to update work item", - detail: error.message - } + detail: error.message, + }, }) }) } @@ -152,7 +162,11 @@ export class WorkItem extends React.Component { @autobind handleMessageDismiss() { + const back = this.state.messageModal.back this.setState({ messageModal: null }) + if (back) { + this.handleBackPress() + } } @autobind @@ -162,7 +176,7 @@ export class WorkItem extends React.Component { this.setState( binder.updateFieldValue( "location", - latLngToString(region.latitude, region.longitude) + formatLatLng(region.latitude, region.longitude) ) ) } @@ -185,7 +199,7 @@ export class WorkItem extends React.Component { binder={binder} name="workItemType" label="Work Item Type:" - options={workItemOptions} + options={workItemTypeEnum} /> @@ -204,17 +218,16 @@ export class WorkItem extends React.Component { justifyContent: "center", width: "100%", height: 400, - marginBottom: 10 + marginBottom: 10, }} zoomControlEnabled initialRegion={{ latitude: 43.653908, longitude: -79.384293, latitudeDelta: 0.0922, - longitudeDelta: 0.0421 + longitudeDelta: 0.0421, }} - onRegionChange={this.handleRegionChange} - > + onRegionChange={this.handleRegionChange}> Pictures: + style={{ flexDirection: "row", justifyContent: "space-between" }}> diff --git a/mobile/src/WorkItem/WorkItemList.js b/mobile/src/WorkItem/WorkItemList.js index ca55658..3c671ff 100644 --- a/mobile/src/WorkItem/WorkItemList.js +++ b/mobile/src/WorkItem/WorkItemList.js @@ -6,122 +6,58 @@ import { TouchableOpacity, Image, FlatList, - Text + Text, } from "react-native" import { Icon, Header } from "../ui" import { MessageModal } from "../Modal" import autobind from "autobind-decorator" import { SwipeListView } from "react-native-swipe-list-view" import { api } from "../API" +import { workItemTypeEnum, formatLatLng, parseLatLng } from "../util" const styles = StyleSheet.create({ container: { height: "100%", width: "100%", justifyContent: "flex-start", - backgroundColor: "#FFFFFF" - } + backgroundColor: "#FFFFFF", + }, }) -const data = [ - { - key: "1", - type: "work", - location: "Ossington Ave. | 0.2 mi.", - state: "open", - latlng: { latitude: 43.653226, longitude: -79.383184 } - }, - { - key: "2", - type: "inspection", - location: "Alexandre St. | 0.7 mi.", - state: "open", - latlng: { latitude: 43.648118, longitude: 79.392636 } - }, - { - key: "3", - type: "complaint", - location: "Bay St. | 0.8 mi.", - state: "open", - latlng: { latitude: 43.640168, longitude: -79.409373 } - }, - { - key: "4", - type: "work", - location: "Bloor St. | 1.2 mi.", - state: "open", - latlng: { latitude: 43.63311, longitude: -79.41588 } - }, - { - key: "5", - type: "inspection", - location: "Blue Jays Way | 2.2 mi.", - state: "open", - latlng: { latitude: 43.653526, longitude: -79.361385 } - }, - { - key: "6", - type: "complaint", - location: "Christie St. | 3.0 mi.", - state: "open", - latlng: { latitude: 43.66387, longitude: -79.383705 } - }, - { - key: "7", - type: "work", - location: "Cummer Ave. | 4.2 mi.", - state: "open", - latlng: { latitude: 43.659166, longitude: -79.39135 } - }, - { - key: "8", - type: "complaint", - location: "Danforth Ave. | 4.7 mi.", - state: "open", - latlng: { latitude: 43.663538, longitude: -79.423212 } - } -] - -const inspectionTypes = { - work: { - title: "Work Order" - }, - inspection: { - title: "Inspection" - }, - complaint: { - title: "Complaint" - } -} +const workItemTypeText = workItemTypeEnum.reduce((result, item) => { + result[item.value] = item.text + return result +}, {}) export class WorkItemList extends React.Component { constructor(props) { super(props) this.state = { - messageModal: null + messageModal: null, } api .listWorkItems() - .then(list => {}) + .then((list) => { + this.setState({ listItems: list.items }) + }) .catch(() => { this.setState({ messageModal: { icon: "hand", message: "Unable to get list of work items", - detail: error.message - } + detail: error.message, + }, }) }) } @autobind handleItemSelect(item, index) { - this.props.history.push("/workitem") + this.props.history.push(`/workitem?id=${item._id}`) } @autobind - handleItemDelete(item, index) { - } + handleItemDelete(item, index) {} @autobind handleMessageDismiss() { @@ -145,7 +81,7 @@ export class WorkItemList extends React.Component { } render() { - const { messageModal } = this.state + const { listItems, messageModal } = this.state return ( @@ -160,9 +96,10 @@ export class WorkItemList extends React.Component { width: "100%", flexGrow: 1, paddingTop: 20, - paddingBottom: 20 + paddingBottom: 20, }} - data={data} + data={listItems} + keyExtractor={(item) => (item._id)} renderItem={({ item, index }) => ( this.handleItemSelect(item, index)} - > - - + underlayColor="#EEEEEE" + onPress={() => this.handleItemSelect(item, index)}> + + - {inspectionTypes[item.type].title} + {workItemTypeText[item.workItemType]} - {item.location} + {`${item.address || "..."} | ??? mi`} this.handleItemDelete(item, index)} - > - + onPress={() => this.handleItemDelete(item, index)}> + Delete @@ -209,7 +156,7 @@ export class WorkItemList extends React.Component { )} rightOpenValue={-80} - stopLeftSwipe={100} + stopRightSwipe={-120} disableRightSwipe /> - {label} + + {label} + + {/* TODO: Handle visible, disabled & read-only */} ) diff --git a/mobile/src/ui/BoundText.js b/mobile/src/ui/BoundText.js new file mode 100644 index 0000000..b1e2521 --- /dev/null +++ b/mobile/src/ui/BoundText.js @@ -0,0 +1,68 @@ +import React from "react" +import PropTypes from "prop-types" +import { View, Text, TouchableHighlight } from "react-native" +import autobind from "autobind-decorator" + +export class BoundText extends React.Component { + static propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string, + value: PropTypes.string, + binder: PropTypes.object.isRequired, + } + + constructor(props) { + super(props) + + const { name, binder } = this.props + + binder.addListener(name, this.updateValue) + this.state = binder.getFieldState(name) + } + + @autobind + updateValue(e) { + this.setState(e.state) + } + + componentWillUnmount() { + this.props.binder.removeListener(this.props.name, this.updateValue) + } + + componentWillReceiveProps(nextProps) { + if (nextProps.binder !== this.props.binder) { + this.props.binder.removeListener(this.props.name, this.updateValue) + nextProps.binder.addListener(nextProps.name, this.updateValue) + this.setState(nextProps.binder.getFieldState(nextProps.name)) + } + } + + render() { + const { name, label, value } = this.props + const { visible, disabled } = this.state + + if (!visible) { + return null + } + + return ( + + + {label} + + + {value} + + + ) + } +} diff --git a/mobile/src/ui/OptionStrip.js b/mobile/src/ui/OptionStrip.js index 305e4a7..e95b919 100644 --- a/mobile/src/ui/OptionStrip.js +++ b/mobile/src/ui/OptionStrip.js @@ -1,30 +1,40 @@ -import React, { Component } from 'react' -import { View, Text, TouchableHighlight } from 'react-native' -import PropTypes from 'prop-types' -import autobind from 'autobind-decorator'; +import React, { Component } from "react" +import { View, Text, TouchableHighlight } from "react-native" +import PropTypes from "prop-types" +import autobind from "autobind-decorator" export class OptionStrip extends Component { static propTypes = { - options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string, text: PropTypes.string })).isRequired, - value: PropTypes.string.isRequired, + options: PropTypes.arrayOf( + PropTypes.shape({ value: PropTypes.string, text: PropTypes.string }) + ).isRequired, + value: PropTypes.string, onValueChanged: PropTypes.func, } constructor(props) { super(props) this.state = { - selectedOption: this.getSelectedOption(props.options, props.value) + selectedOption: this.getSelectedOption(props.options, props.value), } } @autobind getSelectedOption(options, value) { - return options.find((option) => (value === option.value)) || options[0] + return options.find((option) => value === option.value) || null } componentWillReceiveProps(newProps) { - if (newProps.options !== this.props.options || newProps.value !== this.props.value) { - this.setState({ selectedIndex: this.getSelectedIndex(newProps.options, newProps.value)}) + if ( + newProps.options !== this.props.options || + newProps.value !== this.props.value + ) { + this.setState({ + selectedOption: this.getSelectedOption( + newProps.options, + newProps.value + ), + }) } } @@ -42,25 +52,50 @@ export class OptionStrip extends Component { const { style, options, value } = this.props const { selectedOption } = this.state + // TODO: Handle visible, disabled & read-only + return ( - + {options.map((option, index) => ( this.handlePress(option)}> - - {option.text} + + + {option.text} + ))} diff --git a/mobile/src/util.js b/mobile/src/util.js new file mode 100644 index 0000000..24ae396 --- /dev/null +++ b/mobile/src/util.js @@ -0,0 +1,46 @@ +export const geoDistance = (lat1, lng1, lat2, lng2, unit) => { + var radlat1 = Math.PI * lat1 / 180 + var radlat2 = Math.PI * lat2 / 180 + var theta = lng1 - lng2 + var radtheta = Math.PI * theta / 180 + var dist = + Math.sin(radlat1) * Math.sin(radlat2) + + Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta) + dist = Math.acos(dist) + dist = dist * 180 / Math.PI + dist = dist * 60 * 1.1515 + if (unit == "K") { + dist = dist * 1.609344 + } else if (unit == "N") { + dist = dist * 0.8684 + } + return dist +} + +export const formatLatLng = (lat, lng) => + `${Math.abs(lat).toFixed(4)}°${lat >= 0 ? "N" : "S"}, ${Math.abs(lng).toFixed( + 4 + )}°${lng >= 0 ? "E" : "W"}` + +export const parseLatLng = (str) => { + const [lat, lng] = str.split(", ") + return { + type: "Point", + coordinates: [ + parseFloat( + (lng[lng.length - 1] === "W" ? "-" : "") + + lng.substring(0, lng.length - 2) + ), + parseFloat( + (lat[lat.length - 1] === "S" ? "-" : "") + + lat.substring(0, lat.length - 2) + ), + ], + } +} + +export const workItemTypeEnum = [ + { value: "order", text: "Work Order" }, + { value: "inspection", text: "Inspection" }, + { value: "complaint", text: "Complaint" }, +] diff --git a/server/src/api/routes/WorkItemRoutes.js b/server/src/api/routes/WorkItemRoutes.js index 8d06829..db6a61f 100644 --- a/server/src/api/routes/WorkItemRoutes.js +++ b/server/src/api/routes/WorkItemRoutes.js @@ -1,6 +1,6 @@ -import passport from 'passport' -import createError from 'http-errors' -import autobind from 'autobind-decorator' +import passport from "passport" +import createError from "http-errors" +import autobind from "autobind-decorator" @autobind export class WorkItemRoutes { @@ -12,14 +12,31 @@ export class WorkItemRoutes { this.mq = container.mq this.ws = container.ws - app.route('/workitems') - .get(passport.authenticate('bearer', { session: false }), this.listWorkItems) - .post(passport.authenticate('bearer', { session: false }), this.createWorkItem) - .put(passport.authenticate('bearer', { session: false }), this.updateWorkItem) + app + .route("/workitems") + .get( + passport.authenticate("bearer", { session: false }), + this.listWorkItems + ) + .post( + passport.authenticate("bearer", { session: false }), + this.createWorkItem + ) + .put( + passport.authenticate("bearer", { session: false }), + this.updateWorkItem + ) - app.route('/workitems/:_id([a-f0-9]{24})') - .get(passport.authenticate('bearer', { session: false }), this.getWorkItem) - .delete(passport.authenticate('bearer', { session: false }), this.deleteWorkItem) + app + .route("/workitems/:_id([a-f0-9]{24})") + .get( + passport.authenticate("bearer", { session: false }), + this.getWorkItem + ) + .delete( + passport.authenticate("bearer", { session: false }), + this.deleteWorkItem + ) } async listWorkItems(req, res, next) { @@ -33,25 +50,29 @@ export class WorkItemRoutes { const total = await WorkItem.count({}) let workItems = [] - let cursor = WorkItem.find(query).limit(limit).skip(skip).cursor().map((doc) => { - return doc.toClient(partial) - }) + let cursor = WorkItem.find(query) + .limit(limit) + .skip(skip) + .cursor() + .map((doc) => { + return doc.toClient(partial) + }) - cursor.on('data', (doc) => { + cursor.on("data", (doc) => { workItems.push(doc) }) - cursor.on('end', () => { + cursor.on("end", () => { res.json({ total: total, offset: skip, count: workItems.length, - items: workItems + items: workItems, }) }) - cursor.on('error', (err) => { + cursor.on("error", (err) => { throw createError.InternalServerError(err.message) }) - } catch(err) { + } catch (err) { if (err instanceof createError.HttpError) { next(err) } else { @@ -61,7 +82,6 @@ export class WorkItemRoutes { } async createWorkItem(req, res, next) { - try { const isAdmin = req.user.administrator @@ -77,7 +97,7 @@ export class WorkItemRoutes { const newWorkItem = await workItem.save() res.json(newWorkItem.toClient()) - } catch(err) { + } catch (err) { if (err instanceof createError.HttpError) { next(err) } else { @@ -96,7 +116,7 @@ export class WorkItemRoutes { // Do this here because Mongoose will add it automatically otherwise if (!req.body._id) { - throw createError.BadRequest('No _id given in body') + throw createError.BadRequest("No _id given in body") } let WorkItem = this.db.WorkItem @@ -105,13 +125,15 @@ export class WorkItemRoutes { try { workItemUpdates = new WorkItem(req.body) } catch (err) { - throw createError.BadRequest('Invalid data') + throw createError.BadRequest("Invalid data") } const foundWorkItem = await WorkItem.findById(workItemUpdates._id) if (!foundWorkItem) { - return next(createError.NotFound(`WorkItem with _id ${_id} was not found`)) + return next( + createError.NotFound(`WorkItem with _id ${_id} was not found`) + ) } foundWorkItem.merge(workItemUpdates) @@ -119,7 +141,7 @@ export class WorkItemRoutes { const savedWorkItem = await foundWorkItem.save() res.json(savedWorkItem.toClient()) - } catch(err) { + } catch (err) { if (err instanceof createError.HttpError) { next(err) } else { @@ -139,7 +161,7 @@ export class WorkItemRoutes { } res.json(workItem.toClient()) - } catch(err) { + } catch (err) { if (err instanceof createError.HttpError) { next(err) } else { @@ -165,7 +187,7 @@ export class WorkItemRoutes { } res.json({}) - } catch(err) { + } catch (err) { if (err instanceof createError.HttpError) { next(err) } else {