diff --git a/.eslintrc.js b/.eslintrc.js index 2f93580a2..e053723a1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,6 +21,15 @@ module.exports = { ecmaVersion: 2018, sourceType: 'module', }, + settings: { + "import/extensions": [ + ".js", + ".jsx" + ], + 'import/resolver': { + webpack: "webpack.config.js", + }, + }, plugins: [ 'react', 'react-hooks' @@ -28,6 +37,7 @@ module.exports = { rules: { 'linebreak-style': 'off', "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn" + "react-hooks/exhaustive-deps": "warn", + "arrow-parens": ["error", "as-needed"] }, }; diff --git a/.example.env b/.example.env index 05872ca35..39d38b052 100644 --- a/.example.env +++ b/.example.env @@ -1 +1,2 @@ REACT_APP_MAPBOX_TOKEN=REDACTED +DB_URL=REDACTED \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/blank-issue.md b/.github/ISSUE_TEMPLATE/blank-issue.md new file mode 100644 index 000000000..a3c7c04f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/blank-issue.md @@ -0,0 +1,14 @@ +--- +name: Blank Issue +about: Describe this issue's purpose here +title: '' +labels: '' +assignees: '' + +--- + +#Description + +## Action Items + +## Resources diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..f3d5c415e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..60b710323 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Describe a new feature for the project +title: "[FEAT]" +labels: '' +assignees: '' + +--- + +### Overview +REPLACE THIS TEXT -General overview of the feature + +### Action Items +REPLACE THIS TEXT -If the issue has already been researched, and the course of action is clear, this will describe the steps. However, if the steps can be divided into tasks for more than one person, we recommend dividing it up into separate issues, or assigning it as a pair programming task. + +### Resources/Instructions +REPLACE THIS TEXT -If there is a website which has documentation that helps with this issue provide the link(s) here. diff --git a/.github/ISSUE_TEMPLATE/new-backend-service-request.md b/.github/ISSUE_TEMPLATE/new-backend-service-request.md new file mode 100644 index 000000000..df041954b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-backend-service-request.md @@ -0,0 +1,62 @@ +--- +name: New Backend Service request +about: Describe the inputs and outputs of a frontend request to the backend +title: "[Service] Enter service name here" +labels: backend +assignees: '' + +--- + +# Description +Describe the new service purpose here + +## Endpoint +Check or uncheck the supported methods +Accepted methods + - [ ] GET + - [ ] POST + +Path: ```server:port/{ENTER PATH HERE}``` + +## Inputs +Describe a quick overview of the inputs here +**Use JSON code flags to describe the json payload** +```json +{ + "startDate":"2015-01-01", + "endDate":"2015-12-31", + "ncList": ["SUNLAND-TUJUNGA NC"], + "requestTypes":["Homeless Encampment"] +} +``` +## Outputs +Describe a quick overview of the expected outputs here +**Use JSON code flags to describe the json payload** +```json +{ + "lastPulled": "NOW", + "data": [ + { + "ncname": "SUNLAND-TUJUNGA NC", + "requesttype": "Homeless Encampment", + "srnumber": "1-79371671", + "latitude": 34.2500573562, + "longitude": -118.285967224, + "address": "TUJUNGA CANYON BLVD AT PINEWOOD AVE, 91042", + "createddate": 1449835131 + }, + { + "ncname": "SUNLAND-TUJUNGA NC", + "requesttype": "Homeless Encampment", + "srnumber": "1-75982851", + "latitude": 34.2480072639, + "longitude": -118.285966934, + "address": "PINEWOOD AVE AT FOOTHILL BLVD, 91042", + "createddate": 1449245408 + }, + ] +} +``` + +**Additional context** +Add any other context about the feature request here. diff --git a/bin/checkEnv.js b/bin/checkEnv.js new file mode 100644 index 000000000..f659a6ca1 --- /dev/null +++ b/bin/checkEnv.js @@ -0,0 +1,23 @@ + +// verifies that the .env file contains all the keys in .example.env + +const fs = require('fs'); +const dotenv = require('dotenv'); + +function getKeys(fileName) { + try { + return Object.keys(dotenv.parse(fs.readFileSync(fileName))); + } catch { + console.log(`Your ${fileName} file does not exist or is incorrectly formatted.\n`); + process.exit(1); + } +} + +const envKeys = getKeys('.env'), + exampleKeys = getKeys('.example.env'), + missingKeys = exampleKeys.filter(key => !envKeys.includes(key)); + +if (missingKeys.length > 0) { + console.error('You are missing these keys in your .env file:', missingKeys, '\n'); + process.exit(1); +} diff --git a/package.json b/package.json index 72795cc08..8232ca517 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,13 @@ "bulma": "^0.8.0", "bulma-checkradio": "^1.1.1", "bulma-switch": "^2.0.0", + "chart.js": "^2.9.3", + "chartjs-chart-box-and-violin-plot": "^2.2.0", "classnames": "^2.2.6", "dataframe-js": "^1.4.3", + "dotenv": "^8.2.0", "dotenv-webpack": "^1.7.0", + "eslint-import-resolver-webpack": "^0.12.1", "gh-pages": "^2.1.1", "html-webpack-plugin": "^3.2.0", "jest": "^24.9.0", @@ -30,16 +34,19 @@ "redux": "^4.0.4", "redux-devtools-extension": "^2.13.8", "redux-logger": "^3.0.6", + "redux-saga": "^1.1.3", + "regenerator-runtime": "^0.13.3", "webpack-merge": "^4.2.2" }, "scripts": { - "start": "npm run dev", + "start": "npm run check-env && npm run dev", "dev": "webpack-dev-server --config webpack.dev.js --host 0.0.0.0", "build": "webpack --config webpack.prod.js", - "lint": "eslint src/**/*.js*", + "lint": "eslint 'src/**/*.js*'", "test": "jest --passWithNoTests", "predeploy": "npm run build", - "deploy": "gh-pages -d dist" + "deploy": "gh-pages -d dist", + "check-env": "node ./bin/checkEnv" }, "devDependencies": { "@babel/core": "^7.8.4", diff --git a/public/index.html b/public/index.html index 3997002d4..39a8ee63f 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,5 @@ - + diff --git a/server.js b/server.js index c12bbd50c..b56eb49ad 100644 --- a/server.js +++ b/server.js @@ -1,15 +1,16 @@ const express = require('express'); const path = require('path'); + const port = process.env.PORT || 3000; const app = express(); // the __dirname is the current directory from where the script is running -app.use(express.static(__dirname + '/dist')); +app.use(express.static(path.join(__dirname, '/dist'))); // send the user to index html page inspite of the url app.get('*', (req, res) => { res.sendFile(path.resolve(__dirname, 'public/index.html')); }); -console.log(`Listening on port ${port}`) +console.log(`Listening on port ${port}`); app.listen(port); diff --git a/server/requirements.txt b/server/requirements.txt index b0ea661a1..55f85d080 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -32,6 +32,7 @@ requests-async==0.5.0 rfc3986==1.3.2 sanic==19.9.0 Sanic-Cors==0.10.0.post3 +sanic-gzip==0.3.0 Sanic-Plugins-Framework==0.9.2 six==1.14.0 sodapy==2.0.0 diff --git a/server/src/app.py b/server/src/app.py index 4d99c34f0..087b91794 100644 --- a/server/src/app.py +++ b/server/src/app.py @@ -2,6 +2,7 @@ from sanic import Sanic from sanic.response import json from sanic_cors import CORS +from sanic_gzip import Compress from configparser import ConfigParser from threading import Timer from datetime import datetime @@ -10,11 +11,13 @@ from services.time_to_close import time_to_close from services.frequency import frequency from services.pinService import PinService +from services.requestDetailService import RequestDetailService from services.ingress_service import ingress_service from services.sqlIngest import DataHandler app = Sanic(__name__) CORS(app) +compress = Compress() def configure_app(): @@ -31,11 +34,13 @@ def configure_app(): @app.route('/') +@compress.compress() async def index(request): return json('You hit the index') @app.route('/timetoclose') +@compress.compress() async def timetoclose(request): ttc_worker = time_to_close(app.config['Settings']) @@ -50,6 +55,7 @@ async def timetoclose(request): @app.route('/requestfrequency') +@compress.compress() async def requestfrequency(request): freq_worker = frequency(app.config['Settings']) @@ -61,6 +67,7 @@ async def requestfrequency(request): @app.route('/sample-data') +@compress.compress() async def sample_route(request): sample_dataset = {'cool_key': ['value1', 'value2'], app.config['REDACTED']: app.config['REDACTED']} @@ -68,6 +75,7 @@ async def sample_route(request): @app.route('/ingest', methods=["POST"]) +@compress.compress() async def ingest(request): """Accept POST requests with a list of years to import. Query parameter name is 'years', and parameter value is @@ -89,6 +97,7 @@ async def ingest(request): @app.route('/update') +@compress.compress() async def update(request): ingress_worker = ingress_service() return_data = ingress_worker.update() @@ -96,6 +105,7 @@ async def update(request): @app.route('/delete') +@compress.compress() async def delete(request): ingress_worker = ingress_service() return_data = ingress_worker.delete() @@ -103,6 +113,7 @@ async def delete(request): @app.route('/pins', methods=["POST"]) +@compress.compress() async def pinMap(request): pin_worker = PinService(app.config['Settings']) postArgs = request.json @@ -118,7 +129,16 @@ async def pinMap(request): return json(return_data) +@app.route('/servicerequest/', methods=["GET"]) +async def requestDetails(request, srnumber): + detail_worker = RequestDetailService(app.config['Settings']) + + return_data = await detail_worker.get_request_detail(srnumber) + return json(return_data) + + @app.route('/test_multiple_workers') +@compress.compress() async def test_multiple_workers(request): Timer(10.0, print, ["Timer Test."]).start() return json("Done") diff --git a/server/src/services/dataService.py b/server/src/services/dataService.py index 11b22d998..ddf364e4b 100644 --- a/server/src/services/dataService.py +++ b/server/src/services/dataService.py @@ -1,18 +1,22 @@ -import sqlalchemy as db +import datetime import pandas as pd +import sqlalchemy as db class DataService(object): def includeMeta(func): - def inner1(*args, **kwargs): + def innerFunc(*args, **kwargs): dataResponse = func(*args, **kwargs) if 'Error' in dataResponse: return dataResponse - withMeta = {'lastPulled': 'NOW', 'data': dataResponse} + # Will represent last time the ingest pipeline ran + lastPulledTimestamp = datetime.datetime.utcnow() + withMeta = {'lastPulled': lastPulledTimestamp, + 'data': dataResponse} return withMeta - return inner1 + return innerFunc def __init__(self, config=None, tableName="ingest_staging_table"): self.config = config diff --git a/server/src/services/pinService.py b/server/src/services/pinService.py index 8938ba488..9754e50b5 100644 --- a/server/src/services/pinService.py +++ b/server/src/services/pinService.py @@ -16,25 +16,17 @@ async def get_base_pins(self, 'LastPulled': 'Timestamp', 'data': [ { - 'ncname':'String', - 'requesttype':'String', 'srnumber':'String', 'latitude': 'String', 'longitude': 'String', - 'address': 'String', - 'createddate': 'Timestamp' } ] } """ - items = ['ncname', - 'requesttype', - 'srnumber', + items = ['srnumber', 'latitude', - 'longitude', - 'address', - 'createddate'] + 'longitude'] ncs = '\'' + '\', \''.join(ncList) + '\'' requests = '\'' + '\', \''.join(requestTypes) + '\'' diff --git a/server/src/services/requestDetailService.py b/server/src/services/requestDetailService.py new file mode 100644 index 000000000..a01a20d8b --- /dev/null +++ b/server/src/services/requestDetailService.py @@ -0,0 +1,32 @@ +from .dataService import DataService + + +class RequestDetailService(object): + def __init__(self, config=None, tableName="ingest_staging_table"): + self.dataAccess = DataService(config, tableName) + + async def get_request_detail(self, requestNumber=None): + """ + Returns all properties tied to a service request given the srNumber + { + 'LastPulled': 'Timestamp', + 'data': { + 'ncname':'String', + 'requesttype':'String', + 'srnumber':'String', + 'latitude': 'String', + 'longitude': 'String', + 'address': 'String', + 'createddate': 'Timestamp' + . + . + . + } + } + """ + + items = ['*'] + filters = ['srnumber = \'{}\''.format(requestNumber)] + result = self.dataAccess.query(items, filters) + + return result diff --git a/src/App.jsx b/src/App.jsx index 06b378b8c..a56d1914d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,12 +12,12 @@ const App = () => { }, []); return ( -
+ <>
-
+ ); }; diff --git a/src/Util/DataService.js b/src/Util/DataService.js index 5179c250e..bf95a1d84 100644 --- a/src/Util/DataService.js +++ b/src/Util/DataService.js @@ -51,12 +51,12 @@ export function getBroadCallVolume(year, startMonth = 0, endMonth = 13, onBroadD const end = Math.max(startMonth, endMonth); DataFrame.fromJSON(`https://data.lacity.org/resource/${dataResources[year]}.json?$select=count(*)+AS+CallVolume,NCName,RequestType&$where=date_extract_m(CreatedDate)+between+${start}+and+${end}&$group=NCName,RequestType&$order=CallVolume DESC`) - .then((df) => { + .then(df => { df.show(); - const totalCounts = df.groupBy('ncname').aggregate((group) => group.stat.sum('callvolume')).rename('aggregation', 'callvolume'); + const totalCounts = df.groupBy('ncname').aggregate(group => group.stat.sum('callvolume')).rename('aggregation', 'callvolume'); const biggestProblems = {}; - df.toCollection().forEach((row) => { + df.toCollection().forEach(row => { const rhs = parseInt(row.callvolume, 10); const lhs = parseInt(biggestProblems[row.ncname], 10); if (!lhs) { @@ -68,7 +68,7 @@ export function getBroadCallVolume(year, startMonth = 0, endMonth = 13, onBroadD } }); const colorMap = getColorMap(false); - totalCounts.toCollection().forEach((row) => { + totalCounts.toCollection().forEach(row => { const biggestProblem = biggestProblems[`${row.ncname}_biggestproblem`]; const dataPoint = { title: row.ncname, @@ -92,9 +92,9 @@ export function getZoomedCallVolume( const start = Math.min(startMonth, endMonth); const end = Math.max(startMonth, endMonth); - DataFrame.fromJSON(`https://data.lacity.org/resource/${dataResources[year]}.json?$select=count(*)+AS+CallVolume,NCName,RequestType&$where=NCName+=+'${ncName}'+and+date_extract_m(CreatedDate)+between+${start}+and+${end}&$group=NCName,RequestType&$order=CallVolume DESC`).then((df) => { + DataFrame.fromJSON(`https://data.lacity.org/resource/${dataResources[year]}.json?$select=count(*)+AS+CallVolume,NCName,RequestType&$where=NCName+=+'${ncName}'+and+date_extract_m(CreatedDate)+between+${start}+and+${end}&$group=NCName,RequestType&$order=CallVolume DESC`).then(df => { const colorMap = getColorMap(false); - df.toCollection().forEach((row) => { + df.toCollection().forEach(row => { const dataPoint = { title: row.requesttype, color: colorMap[row.requesttype], diff --git a/src/components/PinMap/PinMap.jsx b/src/components/PinMap/PinMap.jsx index 7f1c1de8f..3b1f40607 100644 --- a/src/components/PinMap/PinMap.jsx +++ b/src/components/PinMap/PinMap.jsx @@ -1,6 +1,12 @@ import React, { Component } from 'react'; +import { connect } from 'react-redux'; import { - Map, Marker, Popup, TileLayer, Rectangle, Tooltip, + Map, + Marker, + Popup, + TileLayer, + Rectangle, + Tooltip, } from 'react-leaflet'; import Choropleth from 'react-leaflet-choropleth'; import PropTypes from 'proptypes'; @@ -10,27 +16,20 @@ import PropTypes from 'proptypes'; // import councilDistrictsOverlay from '../../data/la-city-council-districts-2012.json'; import ncOverlay from '../../data/nc-boundary-2019.json'; -const pinMapProps = { - data: PropTypes.string, - showMarkers: PropTypes.boolean, -}; - - class PinMap extends Component { constructor(props) { super(props); this.state = { - position: [34.0173157, -118.2497254], + position: [34.0094213, -118.6008506], zoom: 10, mapUrl: `https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}.png?access_token=${process.env.REACT_APP_MAPBOX_TOKEN}`, - // dataUrl: 'https://data.lacity.org/resource/h65r-yf5i.json?$select=location,zipcode,address,requesttype,status,ncname,streetname,housenumber&$where=date_extract_m(CreatedDate)+between+2+and+3', geoJSON: ncOverlay, bounds: null, }; } - highlightRegion = (e) => { + highlightRegion = e => { const layer = e.target; layer.setStyle({ @@ -43,7 +42,7 @@ class PinMap extends Component { layer.bringToFront(); } - resetRegionHighlight = (e) => { + resetRegionHighlight = e => { const layer = e.target; layer.setStyle({ @@ -56,7 +55,7 @@ class PinMap extends Component { }); } - zoomToRegion = (e) => { + zoomToRegion = e => { const bounds = e.target.getBounds(); this.setState({ bounds }); } @@ -96,7 +95,7 @@ class PinMap extends Component { fillOpacity: 0.7, }} onEachFeature={this.onEachFeature} - ref={(el) => { + ref={el => { if (el) { this.choropleth = el.leafletElement; return this.choropleth; @@ -115,13 +114,12 @@ class PinMap extends Component { const { data, showMarkers } = this.props; if (showMarkers && data) { - return data.map((d) => { - if (d.location) { - const { location } = d; - const position = [location.latitude, location.longitude]; + return data.map(d => { + if (d.latitude && d.longitude) { + const position = [d.latitude, d.longitude]; return ( - + Type: {d.requesttype} @@ -141,7 +139,12 @@ class PinMap extends Component { return ( - + No Data To Display @@ -162,7 +165,7 @@ class PinMap extends Component { center={position} zoom={zoom} bounds={bounds} - style={{ height: '85vh' }} + style={{ height: '88.4vh' }} > ({ + data: state.data.data, +}); + +PinMap.propTypes = { + data: PropTypes.arrayOf(PropTypes.shape({})), + showMarkers: PropTypes.bool, +}; + PinMap.defaultProps = { - data: '', + data: undefined, showMarkers: true, }; -PinMap.propTypes = pinMapProps; - -export default PinMap; +export default connect(mapStateToProps, null)(PinMap); diff --git a/src/components/Visualizations/Chart.jsx b/src/components/Visualizations/Chart.jsx new file mode 100644 index 000000000..0773025de --- /dev/null +++ b/src/components/Visualizations/Chart.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'proptypes'; +import Chart from 'chart.js'; +import 'chartjs-chart-box-and-violin-plot'; +import COLORS from '@styles/COLORS'; + +// ///////// CHARTJS DEFAULTS /////////// + +Object.assign(Chart.defaults.global, { + defaultFontColor: COLORS.FONTS, + defaultFontFamily: 'Roboto', + animation: false, + responsive: true, + maintainAspectRatio: false, + legend: false, +}); + +Object.assign(Chart.defaults.global.title, { + display: true, + fontFamily: 'Open Sans', + fontSize: 20, +}); + +Object.assign(Chart.defaults.scale.scaleLabel, { + display: true, + fontFamily: 'Open Sans', + fontWeight: 'bold', + fontSize: 15, +}); + +Object.assign(Chart.defaults.global.tooltips, { + xPadding: 10, + yPadding: 10, + bodyFontFamily: 'Roboto', + bodyFontSize: 14, + bodyFontColor: COLORS.FONTS, + backgroundColor: 'rgb(200, 200, 200)', + cornerRadius: 4, +}); + +// //////////// COMPONENT ////////////// + +class ReactChart extends React.Component { + canvasRef = React.createRef(); + + componentDidMount() { + const { type, data, options } = this.props; + const ctx = this.canvasRef.current.getContext('2d'); + this.chart = new Chart(ctx, { + type, + data, + options, + }); + this.setHeight(); + } + + componentDidUpdate(prevProps) { + const { data } = this.props; + + if (prevProps.data !== data) { + this.chart.data = data; + this.chart.update(); + this.setHeight(); + } + } + + setHeight = () => { + const { height } = this.props; + + if (height) { + const numLabels = this.chart.data.labels.length; + const heightPx = height(numLabels); + this.canvasRef.current.parentNode.style.height = `${heightPx}px`; + } + } + + render() { + return ( +
+ +
+ ); + } +} + +export default ReactChart; + +ReactChart.propTypes = { + type: PropTypes.string.isRequired, + data: PropTypes.shape.isRequired, + options: PropTypes.shape.isRequired, + height: PropTypes.func, +}; + +ReactChart.defaultProps = { + height: undefined, +}; diff --git a/src/components/Visualizations/Criteria.jsx b/src/components/Visualizations/Criteria.jsx new file mode 100644 index 000000000..acd7e442d --- /dev/null +++ b/src/components/Visualizations/Criteria.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'proptypes'; +import { connect } from 'react-redux'; + +const Criteria = ({ + startDate, + endDate, + councils, +}) => { + const dateText = startDate && endDate + ? `From ${startDate} to ${endDate}` + : 'No date range selected.'; + + const councilsText = councils.length > 0 + ? councils.join('; ') + : 'No councils selected.'; + + return ( +
+

Criteria

+
+
+ + Date Range + + { dateText } +
+
+ + Neighborhood Council District + + { councilsText } +
+
+
+ ); +}; + +const mapStateToProps = state => ({ + startDate: state.data.startDate, + endDate: state.data.endDate, + councils: state.data.councils, +}); + +export default connect(mapStateToProps)(Criteria); + +Criteria.propTypes = { + startDate: PropTypes.string, + endDate: PropTypes.string, + councils: PropTypes.arrayOf(PropTypes.shape({})).isRequired, +}; + +Criteria.defaultProps = { + startDate: undefined, + endDate: undefined, +}; diff --git a/src/components/Visualizations/Frequency.jsx b/src/components/Visualizations/Frequency.jsx new file mode 100644 index 000000000..ab9f2cec3 --- /dev/null +++ b/src/components/Visualizations/Frequency.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'proptypes'; +import { connect } from 'react-redux'; +import { REQUEST_TYPES } from '@components/common/CONSTANTS'; +import moment from 'moment'; +import Chart from './Chart'; + +const Frequency = ({ + requestTypes, +}) => { + // // DATA //// + + const randomPoints = (count, min, max) => Array.from({ length: count }) + .map((el, idx) => ({ + x: moment().add(idx, 'd').toDate(), + y: Math.round(Math.random() * (max - min) + min), + })); + + const dummyData = REQUEST_TYPES.reduce((p, c) => { + const acc = p; + acc[c.type] = randomPoints(10, 20, 200); + return acc; + }, {}); + + const selectedTypes = REQUEST_TYPES.filter(el => requestTypes[el.type]); + + const chartData = { + datasets: selectedTypes.map(t => ({ + label: `${t.abbrev} requests`, + backgroundColor: t.color, + borderColor: t.color, + fill: false, + lineTension: 0, + data: dummyData[t.type], + })), + }; + + // // OPTIONS //// + + const chartOptions = { + aspectRatio: 0.7, + title: { + text: 'Frequency', + fontSize: 20, + }, + scales: { + xAxes: [{ + type: 'time', + time: { + unit: 'day', + round: 'day', + }, + scaleLabel: { + labelString: 'Timeline', + }, + ticks: { + minRotation: 45, + maxRotation: 45, + }, + }], + yAxes: [{ + scaleLabel: { + labelString: '# of Requests', + }, + ticks: { + beginAtZero: true, + }, + }], + }, + tooltips: { + callbacks: { + title: () => null, + }, + }, + }; + + if (chartData.datasets.length === 0) return null; + + return ( +
+ +
+ ); +}; + +const mapStateToProps = state => ({ + requestTypes: state.data.requestTypes, +}); + +export default connect(mapStateToProps)(Frequency); + +Frequency.propTypes = { + requestTypes: PropTypes.shape({}).isRequired, +}; diff --git a/src/components/Visualizations/Legend.jsx b/src/components/Visualizations/Legend.jsx new file mode 100644 index 000000000..451f4a613 --- /dev/null +++ b/src/components/Visualizations/Legend.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'proptypes'; +import { connect } from 'react-redux'; +import { REQUEST_TYPES } from '@components/common/CONSTANTS'; +import Icon from '@components/common/Icon'; + +const Legend = ({ + requestTypes, +}) => { + const selectedTypes = REQUEST_TYPES.filter(el => requestTypes[el.type]); + + return ( +
+

Legend

+
+ { + selectedTypes.length > 0 + ? selectedTypes.map(({ type, color, abbrev }) => ( + + + { type } + {' '} + [ + {abbrev} + ] + + )) + : ( + + No request types selected. + + ) + } +
+
+ ); +}; + +const mapStateToProps = state => ({ + requestTypes: state.data.requestTypes, +}); + +export default connect(mapStateToProps)(Legend); + +Legend.propTypes = { + requestTypes: PropTypes.shape({}).isRequired, +}; diff --git a/src/components/Visualizations/NumberOfRequests.jsx b/src/components/Visualizations/NumberOfRequests.jsx new file mode 100644 index 000000000..dd84e7c40 --- /dev/null +++ b/src/components/Visualizations/NumberOfRequests.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'proptypes'; + +function addCommas(num) { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +const NumberOfRequests = ({ + numRequests, +}) => ( +
+

Number of Requests

+
+ + { addCommas(numRequests) } + +
+
+); + +export default NumberOfRequests; + +NumberOfRequests.propTypes = { + numRequests: PropTypes.number, +}; + +NumberOfRequests.defaultProps = { + numRequests: 1285203, // until we get data +}; diff --git a/src/components/Visualizations/TimeToClose.jsx b/src/components/Visualizations/TimeToClose.jsx new file mode 100644 index 000000000..bd8f17b8f --- /dev/null +++ b/src/components/Visualizations/TimeToClose.jsx @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'proptypes'; +import { connect } from 'react-redux'; +import { REQUEST_TYPES } from '@components/common/CONSTANTS'; +import Chart from './Chart'; + + +const TimeToClose = ({ + requestTypes, +}) => { + // // DATA //// + + const randomSeries = (count, min, max) => Array.from({ length: count }) + .map(() => Math.random() * (max - min) + min); + + const dummyData = REQUEST_TYPES.reduce((p, c) => { + const acc = p; + acc[c.type] = randomSeries(8, 0, 11); + return acc; + }, {}); + + const selectedTypes = REQUEST_TYPES.filter(el => requestTypes[el.type]); + + const chartData = { + labels: selectedTypes.map(t => t.abbrev), + datasets: [{ + data: selectedTypes.map(t => dummyData[t.type]), + backgroundColor: selectedTypes.map(t => t.color), + borderColor: '#000', + borderWidth: 1, + }], + }; + + // // OPTIONS //// + + const chartOptions = { + title: { + text: 'Time to Close', + }, + scales: { + xAxes: [{ + scaleLabel: { + display: true, + labelString: 'Days', + }, + ticks: { + beginAtZero: true, + stepSize: 1, + coef: 0, + }, + }], + yAxes: [{ + scaleLabel: { + labelString: 'Type of Request', + }, + }], + }, + tooltips: { + callbacks: { + title: () => null, + }, + }, + tooltipDecimals: 1, + }; + + // // HEIGHT //// + + const chartHeight = numLabels => ( + numLabels > 0 + ? 100 + (numLabels * 40) + : 0 + ); + + return ( +
+ +
+ ); +}; + +const mapStateToProps = state => ({ + requestTypes: state.data.requestTypes, +}); + +export default connect(mapStateToProps)(TimeToClose); + +TimeToClose.propTypes = { + requestTypes: PropTypes.shape({}).isRequired, +}; diff --git a/src/components/Visualizations/index.jsx b/src/components/Visualizations/index.jsx new file mode 100644 index 000000000..029221dca --- /dev/null +++ b/src/components/Visualizations/index.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'proptypes'; +import { connect } from 'react-redux'; +import clx from 'classnames'; +import { MENU_TABS } from '@components/common/CONSTANTS'; +import Criteria from './Criteria'; +import Legend from './Legend'; +import NumberOfRequests from './NumberOfRequests'; +import TimeToClose from './TimeToClose'; +import Frequency from './Frequency'; + +const Visualizations = ({ + menuIsOpen, + menuActiveTab, +}) => { + if (menuActiveTab !== MENU_TABS.VISUALIZATIONS) return null; + + return ( +
+ + + + + +
+ ); +}; + +const mapStateToProps = state => ({ + menuIsOpen: state.ui.menu.isOpen, + menuActiveTab: state.ui.menu.activeTab, +}); + +export default connect(mapStateToProps)(Visualizations); + +Visualizations.propTypes = { + menuIsOpen: PropTypes.bool.isRequired, + menuActiveTab: PropTypes.string.isRequired, +}; diff --git a/src/components/common/CONSTANTS.js b/src/components/common/CONSTANTS.js index 16e0e631b..2c323531a 100644 --- a/src/components/common/CONSTANTS.js +++ b/src/components/common/CONSTANTS.js @@ -205,3 +205,8 @@ export const COUNCILS = [ 'WOODLAND HILLS-WARNER CENTER NC', 'ZAPATA KING NC', ]; + +export const MENU_TABS = { + MAP: 'Map', + VISUALIZATIONS: 'Data Visualization', +}; diff --git a/src/components/common/Dropdown.jsx b/src/components/common/Dropdown.jsx index 6ff448565..f174f2307 100644 --- a/src/components/common/Dropdown.jsx +++ b/src/components/common/Dropdown.jsx @@ -29,7 +29,7 @@ const Dropdown = ({ }, className); useEffect(() => { - const handleClickOutside = (e) => { + const handleClickOutside = e => { // Clicked inside dropdown if (dropdownNode.current.contains(e.target) || !isOpen) { return; @@ -38,7 +38,7 @@ const Dropdown = ({ updateIsOpen(false); }; - const handleEscapeKeydown = (e) => { + const handleEscapeKeydown = e => { // Non-esc key pressed if (e.keyCode !== 27 || !isOpen) { return; @@ -60,16 +60,16 @@ const Dropdown = ({ }; }, [isOpen, currentSelection]); - const toggleOpen = () => updateIsOpen((prevIsOpen) => !prevIsOpen); + const toggleOpen = () => updateIsOpen(prevIsOpen => !prevIsOpen); - const handleItemClick = (e) => { + const handleItemClick = e => { e.preventDefault(); updateSelection(e.currentTarget.textContent); updateIsOpen(false); onClick(e.currentTarget.getAttribute('value')); }; - const renderDropdownItems = (items) => items.map((item) => ( + const renderDropdownItems = items => items.map(item => ( ( + data-place={position} + > { children } ); @@ -21,8 +22,12 @@ HoverOverInfo.propTypes = { title: PropTypes.string, text: PropTypes.string, position: PropTypes.oneOf(['top', 'bottom', 'left', 'right']), + children: PropTypes.element, }; HoverOverInfo.defaultProps = { - position: 'right' + title: undefined, + text: undefined, + position: 'right', + children: (
), }; diff --git a/src/components/common/Icon.jsx b/src/components/common/Icon.jsx index 9ee4ea8a9..52f2280c7 100644 --- a/src/components/common/Icon.jsx +++ b/src/components/common/Icon.jsx @@ -1,3 +1,5 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ import React from 'react'; import PropTypes from 'proptypes'; import classNames from 'classnames'; @@ -12,6 +14,7 @@ const Icon = ({ */ size, iconSize, + iconStyle, className, fixedWidth, spin, @@ -44,7 +47,7 @@ const Icon = ({ className={containerClassName} style={style} > - + {label} ); @@ -86,6 +89,7 @@ Icon.propTypes = { pulse: PropTypes.bool, bordered: PropTypes.bool, style: PropTypes.shape({}), + iconStyle: PropTypes.shape({}), }; Icon.defaultProps = { @@ -101,4 +105,5 @@ Icon.defaultProps = { pulse: false, bordered: false, style: undefined, + iconStyle: undefined, }; diff --git a/src/components/main/body/Body.jsx b/src/components/main/body/Body.jsx index 8b8499990..4893a9bff 100644 --- a/src/components/main/body/Body.jsx +++ b/src/components/main/body/Body.jsx @@ -1,12 +1,16 @@ import React from 'react'; +import Visualizations from '@components/Visualizations'; import Menu from '../menu/Menu'; import PinMap from '../../PinMap/PinMap'; const Body = () => ( -
+
- +
+ + +
); diff --git a/src/components/main/footer/Footer.jsx b/src/components/main/footer/Footer.jsx index 6726e0e14..5ff72142b 100644 --- a/src/components/main/footer/Footer.jsx +++ b/src/components/main/footer/Footer.jsx @@ -1,4 +1,8 @@ import React from 'react'; +import { connect } from 'react-redux'; +import propTypes from 'proptypes'; +import moment from 'moment'; + import COLORS from '../../../styles/COLORS'; const footerTextStyle = { @@ -7,7 +11,9 @@ const footerTextStyle = { width: '100vw', }; -const Footer = () => ( +const Footer = ({ + lastUpdated, +}) => (
(

Data Updated Through: +   + {lastUpdated && moment(lastUpdated).format('MMMM Do YYYY, h:mm:ss a')}

); -export default Footer; +const mapStateToProps = state => ({ + lastUpdated: state.data.lastUpdated, +}); + +Footer.propTypes = { + lastUpdated: propTypes.string, +}; + +Footer.defaultProps = { + lastUpdated: undefined, +}; + +export default connect(mapStateToProps, null)(Footer); diff --git a/src/components/main/header/Header.jsx b/src/components/main/header/Header.jsx index d709ab035..6b70222a1 100644 --- a/src/components/main/header/Header.jsx +++ b/src/components/main/header/Header.jsx @@ -1,7 +1,11 @@ import React from 'react'; +import propTypes from 'proptypes'; +import { connect } from 'react-redux'; import COLORS from '../../../styles/COLORS'; -const Header = () => { +const Header = ({ + data, +}) => { const cta2Style = { color: COLORS.BRAND.CTA2, fontWeight: 'bold', @@ -18,46 +22,86 @@ const Header = () => { }; return ( -
-
-
-

311

-

Data

-
-
- -
+ {/* Errors */} + {data.error && ( +
+
+
+
+ {data.error.message} +
+
+
+
+
+
+ )} + ); }; -export default Header; +const mapStateToProps = state => ({ + data: state.data, +}); + +Header.propTypes = { + data: propTypes.shape({ + error: propTypes.shape({ + message: propTypes.string, + }), + }), +}; + +Header.defaultProps = { + data: undefined, +}; + +export default connect(mapStateToProps, null)(Header); diff --git a/src/components/main/menu/DateSelector/DateRangePicker.jsx b/src/components/main/menu/DateSelector/DateRangePicker.jsx index 1f27032db..aec3cb38e 100644 --- a/src/components/main/menu/DateSelector/DateRangePicker.jsx +++ b/src/components/main/menu/DateSelector/DateRangePicker.jsx @@ -121,7 +121,7 @@ const DateRangePicker = ({ showMonthDropdown showPopperArrow={false} popperPlacement="right" - onChange={(date) => handleDateChange(updateLocalStart, date)} + onChange={date => handleDateChange(updateLocalStart, date)} placeholderText="MM/DD/YYYY" />
@@ -148,7 +148,7 @@ const DateRangePicker = ({ selectsEnd showPopperArrow={false} popperPlacement="right" - onChange={(date) => handleDateChange(updateLocalEnd, date)} + onChange={date => handleDateChange(updateLocalEnd, date)} placeholderText="MM/DD/YYYY" /> @@ -178,7 +178,7 @@ const DateRangePicker = ({ }} handleClick={() => { if (startDate && endDate) { - const formatDate = (date) => moment(date).format('MM/DD/YYYY'); + const formatDate = date => moment(date).format('MM/DD/YYYY'); updateStart(formatDate(startDate)); updateEnd(formatDate(endDate)); handleClick(); @@ -193,9 +193,9 @@ const DateRangePicker = ({ ); }; -const mapDispatchToProps = (dispatch) => ({ - updateStart: (newStartDate) => dispatch(updateStartDate(newStartDate)), - updateEnd: (newEndDate) => dispatch(updateEndDate(newEndDate)), +const mapDispatchToProps = dispatch => ({ + updateStart: newStartDate => dispatch(updateStartDate(newStartDate)), + updateEnd: newEndDate => dispatch(updateEndDate(newEndDate)), }); export default connect( diff --git a/src/components/main/menu/DateSelector/DateSelector.jsx b/src/components/main/menu/DateSelector/DateSelector.jsx index df0845a1f..d22eac842 100644 --- a/src/components/main/menu/DateSelector/DateSelector.jsx +++ b/src/components/main/menu/DateSelector/DateSelector.jsx @@ -15,7 +15,7 @@ import HoverOverInfo from '../../../common/HoverOverInfo'; import COLORS from '../../../../styles/COLORS'; -const getDates = (dateOptionValue) => { +const getDates = dateOptionValue => { let newStartDate; const newEndDate = moment().format('MM/DD/YYYY'); const formatPriorDate = (num, timeInterval) => moment().subtract(num, timeInterval).format('MM/DD/YYYY'); @@ -62,7 +62,7 @@ const DateSelector = ({ const [modalOpen, setModalOpen] = useState(false); useEffect(() => { - const handleEscapeClick = (e) => { + const handleEscapeClick = e => { if (e.keyCode !== 27) { return; } @@ -92,12 +92,14 @@ const DateSelector = ({
+ style={{ paddingRight: '10px' }} + > Date Range Selection + text="This filter allows the user to choose a date range for 311 data." + > { + onClick={dateOption => { if (dateOption !== 'CUSTOM_DATE_RANGE') { const { newStartDate, newEndDate } = getDates(dateOption); updateStart(newStartDate); @@ -142,14 +144,14 @@ const DateSelector = ({ ); }; -const mapStateToProps = (state) => ({ +const mapStateToProps = state => ({ startDate: state.data.startDate, endDate: state.data.endDate, }); -const mapDispatchToProps = (dispatch) => ({ - updateStart: (newStartDate) => dispatch(updateStartDate(newStartDate)), - updateEnd: (newEndDate) => dispatch(updateEndDate(newEndDate)), +const mapDispatchToProps = dispatch => ({ + updateStart: newStartDate => dispatch(updateStartDate(newStartDate)), + updateEnd: newEndDate => dispatch(updateEndDate(newEndDate)), }); export default connect( diff --git a/src/components/main/menu/Menu.jsx b/src/components/main/menu/Menu.jsx index 4464afd38..e6a0213cc 100644 --- a/src/components/main/menu/Menu.jsx +++ b/src/components/main/menu/Menu.jsx @@ -1,32 +1,37 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ -import React, { useState } from 'react'; +import React from 'react'; +import PropTypes from 'proptypes'; +import { connect } from 'react-redux'; import { slide as Sidebar } from 'react-burger-menu'; +import { + toggleMenu as reduxToggleMenu, + setMenuTab as reduxSetMenuTab, +} from '@reducers/ui'; +import { MENU_TABS } from '@components/common/CONSTANTS'; + import Button from '../../common/Button'; +import Submit from './Submit'; import DateSelector from './DateSelector/DateSelector'; import NCSelector from './NCSelector'; import RequestTypeSelector from './RequestTypeSelector'; -// const buildDataUrl = () => { -// return `https://data.lacity.org/resource/${dataResources[year]}.json?$select=location,zipcode,address,requesttype,status,ncname,streetname,housenumber&$where=date_extract_m(CreatedDate)+between+${startMonth}+and+${endMonth}+and+requesttype='${request}'`; -// }; - -const Menu = () => { - const [isOpen, setIsOpen] = useState(true); - const [activeTab, setActiveTab] = useState('Map'); +const Menu = ({ + isOpen, + activeTab, + toggleMenu, + setMenuTab, +}) => { const sidebarWidth = '509px'; const tabs = [ - 'Map', - 'Data Visualization', + MENU_TABS.MAP, + MENU_TABS.VISUALIZATIONS, ]; - const handleActiveTab = (tab) => (tab === activeTab ? 'is-active' : ''); - const handleTabClick = (tab) => { - setActiveTab(tab); - }; + const handleActiveTab = tab => (tab === activeTab ? 'is-active' : ''); return (
@@ -65,13 +70,13 @@ const Menu = () => { }} >
    - {tabs.map((tab) => ( + {tabs.map(tab => (
  • - { handleTabClick(tab); }}> + { setMenuTab(tab); }}> {tab}
  • @@ -92,7 +97,7 @@ const Menu = () => { boxShadow: '0 1px 2px rgba(0, 0, 0, 0.5)', borderRadius: '0', }} - handleClick={() => setIsOpen(!isOpen)} + handleClick={() => toggleMenu()} color="light" /> @@ -108,6 +113,7 @@ const Menu = () => { +
@@ -115,6 +121,26 @@ const Menu = () => { ); }; -// const mapStateToProps = state => ({}); +const mapStateToProps = state => ({ + isOpen: state.ui.menu.isOpen, + activeTab: state.ui.menu.activeTab, +}); + +const mapDispatchToProps = dispatch => ({ + toggleMenu: () => dispatch(reduxToggleMenu()), + setMenuTab: tab => dispatch(reduxSetMenuTab(tab)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Menu); -export default Menu; +Menu.propTypes = { + isOpen: PropTypes.bool.isRequired, + activeTab: PropTypes.string.isRequired, + toggleMenu: PropTypes.func, + setMenuTab: PropTypes.func, +}; + +Menu.defaultProps = { + toggleMenu: () => null, + setMenuTab: () => null, +}; diff --git a/src/components/main/menu/NCSelector.jsx b/src/components/main/menu/NCSelector.jsx index c70ccd556..d8857e878 100644 --- a/src/components/main/menu/NCSelector.jsx +++ b/src/components/main/menu/NCSelector.jsx @@ -29,15 +29,20 @@ const NCSelector = ({ width: '280px', }; - const handleSearch = (e) => { + const checkboxStyleFix = { + padding: '10px', + margin: '0', + }; + + const handleSearch = e => { const term = e.target.value; const searchFilter = new RegExp(term, 'i'); - const searchList = COUNCILS.filter((council) => searchFilter.test(council)); + const searchList = COUNCILS.filter(council => searchFilter.test(council)); setFilteredCouncilList(searchList); setSearchValue(e.target.value); }; - const handleSelectCouncil = (council) => { + const handleSelectCouncil = council => { const newSelectedCouncilList = { ...selectedCouncilList }; switch (council) { @@ -49,7 +54,7 @@ const NCSelector = ({ value = false; } - Object.keys(newSelectedCouncilList).forEach((c) => { + Object.keys(newSelectedCouncilList).forEach(c => { newSelectedCouncilList[c] = value; }); break; @@ -60,7 +65,7 @@ const NCSelector = ({ break; } - const newNCList = Object.keys(newSelectedCouncilList).filter((c) => newSelectedCouncilList[c] && c !== 'all'); + const newNCList = Object.keys(newSelectedCouncilList).filter(c => newSelectedCouncilList[c] && c !== 'all'); setSelectedCouncilList(newSelectedCouncilList); updateNCList(newNCList); @@ -75,7 +80,8 @@ const NCSelector = ({ + text="This filter allows the user to select specific neighborhood councils." + > handleSelectCouncil('all')} checked={selectedCouncilList?.all ?? false} + style={checkboxStyleFix} /> - {filteredCouncilList.map((council) => ( + {filteredCouncilList.map(council => (
@@ -153,8 +161,10 @@ const NCSelector = ({
handleSelectCouncil(council)} checked={selectedCouncilList?.[council] ?? false} + style={checkboxStyleFix} />
@@ -166,8 +176,8 @@ const NCSelector = ({ ); }; -const mapDispatchToProps = (dispatch) => ({ - updateNCList: (council) => dispatch(updateNC(council)), +const mapDispatchToProps = dispatch => ({ + updateNCList: council => dispatch(updateNC(council)), }); NCSelector.propTypes = { diff --git a/src/components/main/menu/RequestTypeSelector.jsx b/src/components/main/menu/RequestTypeSelector.jsx index c3c0c3db5..096a90548 100644 --- a/src/components/main/menu/RequestTypeSelector.jsx +++ b/src/components/main/menu/RequestTypeSelector.jsx @@ -25,7 +25,7 @@ const checkboxStyle = { paddingLeft: '3px', }; -const midIndex = ((list) => { +const midIndex = (list => { if (list.length / 2 === 0) { return (list.length / 2); } @@ -77,12 +77,12 @@ const RequestTypeSelector = ({ selectAll, deselectAll, }) => { - const handleItemClick = (e) => { + const handleItemClick = e => { const type = e.target.value; selectType(type); }; - const renderRequestItems = (items) => items.map((item) => ( + const renderRequestItems = items => items.map(item => ( + text="This filter allows the user to choose specific 311 data types." + > ({ +const mapStateToProps = state => ({ requestTypes: state.data.requestTypes, }); -const mapDispatchToProps = (dispatch) => ({ - selectType: (type) => dispatch(updateRequestType(type)), +const mapDispatchToProps = dispatch => ({ + selectType: type => dispatch(updateRequestType(type)), selectAll: () => dispatch(selectAllRequestTypes()), deselectAll: () => dispatch(deselectAllRequestTypes()), }); diff --git a/src/components/main/menu/Submit.jsx b/src/components/main/menu/Submit.jsx new file mode 100644 index 000000000..a7ed97eac --- /dev/null +++ b/src/components/main/menu/Submit.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import propTypes from 'proptypes'; +import { getDataRequest } from '../../../redux/reducers/data'; +import Button from '../../common/Button'; + +const Submit = ({ + getData, +}) => { + const handleSubmit = () => { + getData(); + }; + + return ( +
+
+
+
+ ); +}; + +const mapDispatchToProps = dispatch => ({ + getData: () => dispatch(getDataRequest()), +}); + +Submit.propTypes = { + getData: propTypes.func, +}; + +Submit.defaultProps = { + getData: () => null, +}; + +export default connect(null, mapDispatchToProps)(Submit); diff --git a/src/components/main/tooltip/Tooltip.jsx b/src/components/main/tooltip/Tooltip.jsx index 870afdf9c..3d1e703d6 100644 --- a/src/components/main/tooltip/Tooltip.jsx +++ b/src/components/main/tooltip/Tooltip.jsx @@ -9,12 +9,13 @@ const Tooltip = () => ( effect="solid" getContent={data => { if (!data) return null; - let { text, title } = JSON.parse(data); + const { text, title } = JSON.parse(data); if (!text && !title) return null; return (
- { title && + { title + && (
( /> { title }
- } - { text && + )} + { text + && (
{ text }
- } + )}
); }} diff --git a/src/index.js b/src/index.js index a2d6923d9..72ca1a286 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,6 @@ /* eslint-disable react/jsx-filename-extension */ +import 'regenerator-runtime/runtime'; import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; diff --git a/src/redux/reducers/data.js b/src/redux/reducers/data.js index 83a69cf4a..e068239cf 100644 --- a/src/redux/reducers/data.js +++ b/src/redux/reducers/data.js @@ -1,27 +1,28 @@ -// import axios from 'axios'; - -const types = { +export const types = { UPDATE_START_DATE: 'UPDATE_START_DATE', UPDATE_END_DATE: 'UPDATE_END_DATE', UPDATE_REQUEST_TYPE: 'UPDATE_REQUEST_TYPE', UPDATE_NEIGHBORHOOD_COUNCIL: 'UPDATE_NEIGHBORHOOD_COUNCIL', + GET_DATA_REQUEST: 'GET_DATA_REQUEST', + GET_DATA_SUCCESS: 'GET_DATA_SUCCESS', + GET_DATA_FAILURE: 'GET_DATA_FAILURE', SELECT_ALL_REQUEST_TYPES: 'SELECT_ALL_REQUEST_TYPES', DESELECT_ALL_REQUEST_TYPES: 'DESELECT_ALL_REQUEST_TYPES', }; -export const updateStartDate = (newStartDate) => ({ +export const updateStartDate = newStartDate => ({ type: types.UPDATE_START_DATE, payload: newStartDate, }); -export const updateEndDate = (newEndDate) => ({ +export const updateEndDate = newEndDate => ({ type: types.UPDATE_END_DATE, payload: newEndDate, }); -export const updateRequestType = (requestType) => ({ +export const updateRequestType = requestTypes => ({ type: types.UPDATE_REQUEST_TYPE, - payload: requestType, + payload: requestTypes, }); export const selectAllRequestTypes = () => ({ @@ -32,15 +33,33 @@ export const deselectAllRequestTypes = () => ({ type: types.DESELECT_ALL_REQUEST_TYPES, }); -export const updateNC = (council) => ({ +export const updateNC = council => ({ type: types.UPDATE_NEIGHBORHOOD_COUNCIL, payload: council, }); +export const getDataRequest = () => ({ + type: types.GET_DATA_REQUEST, +}); + +export const getDataSuccess = response => ({ + type: types.GET_DATA_SUCCESS, + payload: response, +}); + +export const getDataFailure = error => ({ + type: types.GET_DATA_FAILURE, + payload: error, +}); + const initialState = { startDate: null, endDate: null, councils: [], + data: [], + isLoading: false, + error: null, + lastUpdated: null, requestTypes: { All: false, 'Dead Animal': false, @@ -103,13 +122,37 @@ export default (state = initialState, action) => { case types.DESELECT_ALL_REQUEST_TYPES: return { ...state, - requestTypes: initialState.requestTypes + requestTypes: initialState.requestTypes, }; case types.UPDATE_NEIGHBORHOOD_COUNCIL: return { ...state, councils: action.payload, }; + case types.GET_DATA_REQUEST: + return { + ...state, + isLoading: true, + }; + case types.GET_DATA_SUCCESS: { + const { data, lastPulled: lastUpdated } = action.payload; + + return { + ...state, + data, + error: null, + isLoading: false, + lastUpdated, + }; + } + case types.GET_DATA_FAILURE: { + const { error } = action.payload; + return { + ...state, + error, + isLoading: false, + }; + } default: return state; } diff --git a/src/redux/reducers/ui.js b/src/redux/reducers/ui.js new file mode 100644 index 000000000..a6ebe75e5 --- /dev/null +++ b/src/redux/reducers/ui.js @@ -0,0 +1,45 @@ +import { MENU_TABS } from '@components/common/CONSTANTS'; + +const types = { + TOGGLE_MENU: 'TOGGLE_MENU', + SET_MENU_TAB: 'SET_MENU_TAB', +}; + +export const toggleMenu = () => ({ + type: types.TOGGLE_MENU, +}); + +export const setMenuTab = tab => ({ + type: types.SET_MENU_TAB, + payload: tab, +}); + +const initialState = { + menu: { + isOpen: true, + activeTab: MENU_TABS.MAP, + }, +}; + +export default (state = initialState, action) => { + switch (action.type) { + case types.TOGGLE_MENU: + return { + ...state, + menu: { + ...state.menu, + isOpen: !state.menu.isOpen, + }, + }; + case types.SET_MENU_TAB: + return { + ...state, + menu: { + ...state.menu, + activeTab: action.payload, + }, + }; + default: + return state; + } +}; diff --git a/src/redux/rootReducer.js b/src/redux/rootReducer.js index 35cc6f54c..dd4397764 100644 --- a/src/redux/rootReducer.js +++ b/src/redux/rootReducer.js @@ -1,6 +1,8 @@ import { combineReducers } from 'redux'; import data from './reducers/data'; +import ui from './reducers/ui'; export default combineReducers({ data, + ui, }); diff --git a/src/redux/rootSaga.js b/src/redux/rootSaga.js new file mode 100644 index 000000000..a39f92139 --- /dev/null +++ b/src/redux/rootSaga.js @@ -0,0 +1,42 @@ +import axios from 'axios'; +import { + takeLatest, + call, + put, + select, +} from 'redux-saga/effects'; +import { + types, + getDataSuccess, + getDataFailure, +} from './reducers/data'; + +const getState = (state, slice) => state[slice]; + +function* getData() { + const { + startDate, + endDate, + councils, + requestTypes, + } = yield select(getState, 'data'); + + const options = { + startDate, + endDate, + ncList: councils, + requestTypes: Object.keys(requestTypes).filter(req => req !== 'All' && requestTypes[req]), + }; + + try { + const response = yield call(axios.post, process.env.DB_URL, options); + const { data } = response; + yield put(getDataSuccess(data)); + } catch (e) { + yield put(getDataFailure(e)); + } +} + +export default function* rootSaga() { + yield takeLatest(types.GET_DATA_REQUEST, getData); +} diff --git a/src/redux/store.js b/src/redux/store.js index f853f25d8..e02afce7c 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -1,10 +1,13 @@ import { applyMiddleware, createStore } from 'redux'; +import createSagaMiddleware from 'redux-saga'; import logger from 'redux-logger'; import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; import rootReducer from './rootReducer'; +import rootSaga from './rootSaga'; -const middlewares = []; +const sagaMiddleware = createSagaMiddleware(); +const middlewares = [sagaMiddleware]; if (process.env.NODE_ENV === 'development') { middlewares.push(logger); @@ -14,4 +17,6 @@ const store = createStore(rootReducer, composeWithDevTools( applyMiddleware(...middlewares), )); +sagaMiddleware.run(rootSaga); + export default store; diff --git a/src/styles/main/_visualizations.scss b/src/styles/main/_visualizations.scss new file mode 100644 index 000000000..c5e2d0343 --- /dev/null +++ b/src/styles/main/_visualizations.scss @@ -0,0 +1,65 @@ +.visualizations { + + // these values are coordinated with the sidebar menu + $menu-width: 509px; + $menu-transition: left 0.5s ease 0s; + + z-index: 1000; + background-color: $brand-bg-color; + overflow: scroll; + padding: 30px 50px; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: $menu-width; + transition: $menu-transition; + + &.full-width { + left: 0; + } + + & > :not(.criteria) { + margin-top: 30px; + } + + h1 { + margin-bottom: 4px; + } + + .outline { + border: 1px solid $brand-stroke-color; + padding: 20px; + } + + .criteria { + &-type { + display: inline-block; + margin-right: 10px; + } + .council-districts { + margin-top: 8px; + } + } + + .legend { + .outline { + padding-bottom: 10px; + column-width: 200px; + } + &-item { + display: inline-block; + margin: 0 15px 10px 0; + } + } + + .number-of-requests { + .requests-box { + display: inline-block; + margin: 0 auto; + border: 4px grey solid; + border-color: $brand-stroke-color; + padding: 8px 16px; + } + } +} diff --git a/src/styles/styles.css b/src/styles/styles.css new file mode 100644 index 000000000..3d7742fbb --- /dev/null +++ b/src/styles/styles.css @@ -0,0 +1,3 @@ +.sidebar-html-class, .sidebar-body-class { + overflow: hidden; +} \ No newline at end of file diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 71ae456d5..df1effa3a 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -6,3 +6,4 @@ @import './main/datepicker'; @import './main/tooltip'; +@import './main/visualizations'; diff --git a/webpack.config.js b/webpack.config.js index 2182956cb..aa841a4c6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,7 +1,7 @@ const Dotenv = require('dotenv-webpack'); +const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const path = require('path'); module.exports = { entry: './src/index.js', @@ -11,6 +11,11 @@ module.exports = { }, resolve: { extensions: ['.js', '.jsx'], + alias: { + '@components': path.resolve(__dirname, 'src/components'), + '@reducers': path.resolve(__dirname, 'src/redux/reducers'), + '@styles': path.resolve(__dirname, 'src/styles'), + }, }, module: { rules: [ @@ -27,8 +32,8 @@ module.exports = { { loader: MiniCssExtractPlugin.loader, options: { - hmr: true - } + hmr: true, + }, }, { loader: 'css-loader',