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/package.json b/package.json index f03f96322..8232ca517 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "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", @@ -33,13 +34,15 @@ "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 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", 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/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.js b/src/components/Visualizations/Chart.jsx similarity index 79% rename from src/components/Visualizations/Chart.js rename to src/components/Visualizations/Chart.jsx index efb528d39..0773025de 100644 --- a/src/components/Visualizations/Chart.js +++ b/src/components/Visualizations/Chart.jsx @@ -4,7 +4,7 @@ import Chart from 'chart.js'; import 'chartjs-chart-box-and-violin-plot'; import COLORS from '@styles/COLORS'; -/////////// CHARTJS DEFAULTS /////////// +// ///////// CHARTJS DEFAULTS /////////// Object.assign(Chart.defaults.global, { defaultFontColor: COLORS.FONTS, @@ -38,10 +38,9 @@ Object.assign(Chart.defaults.global.tooltips, { cornerRadius: 4, }); -////////////// COMPONENT ////////////// +// //////////// COMPONENT ////////////// class ReactChart extends React.Component { - canvasRef = React.createRef(); componentDidMount() { @@ -56,18 +55,22 @@ class ReactChart extends React.Component { } componentDidUpdate(prevProps) { - if (prevProps.data !== this.props.data) { - this.chart.data = this.props.data; + const { data } = this.props; + + if (prevProps.data !== data) { + this.chart.data = data; this.chart.update(); this.setHeight(); } } setHeight = () => { - if (this.props.height) { + const { height } = this.props; + + if (height) { const numLabels = this.chart.data.labels.length; - const height = this.props.height(numLabels); - this.canvasRef.current.parentNode.style.height = height + 'px'; + const heightPx = height(numLabels); + this.canvasRef.current.parentNode.style.height = `${heightPx}px`; } } @@ -84,8 +87,8 @@ export default ReactChart; ReactChart.propTypes = { type: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - options: PropTypes.object.isRequired, + data: PropTypes.shape.isRequired, + options: PropTypes.shape.isRequired, height: PropTypes.func, }; diff --git a/src/components/Visualizations/Criteria.jsx b/src/components/Visualizations/Criteria.jsx index f940dfb8f..acd7e442d 100644 --- a/src/components/Visualizations/Criteria.jsx +++ b/src/components/Visualizations/Criteria.jsx @@ -47,7 +47,7 @@ export default connect(mapStateToProps)(Criteria); Criteria.propTypes = { startDate: PropTypes.string, endDate: PropTypes.string, - councils: PropTypes.array.isRequired, + councils: PropTypes.arrayOf(PropTypes.shape({})).isRequired, }; Criteria.defaultProps = { diff --git a/src/components/Visualizations/Frequency.jsx b/src/components/Visualizations/Frequency.jsx index 1ec043091..ab9f2cec3 100644 --- a/src/components/Visualizations/Frequency.jsx +++ b/src/components/Visualizations/Frequency.jsx @@ -1,43 +1,41 @@ import React from 'react'; import PropTypes from 'proptypes'; import { connect } from 'react-redux'; -import Chart from './Chart'; import { REQUEST_TYPES } from '@components/common/CONSTANTS'; import moment from 'moment'; +import Chart from './Chart'; const Frequency = ({ requestTypes, }) => { + // // DATA //// - //// DATA //// - - const randomPoints = (count, min, max) => { - return Array.from({ length: count }) - .map((el, idx) => ({ - x: moment().add(idx, 'd').toDate(), - y: Math.round(Math.random() * (max - min) + min) - })); - }; + 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) => { - p[c.type] = randomPoints(10, 20, 200); - return p; + 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', + label: `${t.abbrev} requests`, backgroundColor: t.color, borderColor: t.color, fill: false, lineTension: 0, data: dummyData[t.type], - })) + })), }; - //// OPTIONS //// + // // OPTIONS //// const chartOptions = { aspectRatio: 0.7, @@ -67,7 +65,7 @@ const Frequency = ({ ticks: { beginAtZero: true, }, - }] + }], }, tooltips: { callbacks: { @@ -76,8 +74,7 @@ const Frequency = ({ }, }; - if (chartData.datasets.length === 0) - return null; + if (chartData.datasets.length === 0) return null; return (
@@ -88,7 +85,7 @@ const Frequency = ({ />
); -} +}; const mapStateToProps = state => ({ requestTypes: state.data.requestTypes, @@ -97,5 +94,5 @@ const mapStateToProps = state => ({ export default connect(mapStateToProps)(Frequency); Frequency.propTypes = { - requestTypes: PropTypes.object.isRequired, + requestTypes: PropTypes.shape({}).isRequired, }; diff --git a/src/components/Visualizations/Legend.jsx b/src/components/Visualizations/Legend.jsx index a8cbc3449..451f4a613 100644 --- a/src/components/Visualizations/Legend.jsx +++ b/src/components/Visualizations/Legend.jsx @@ -14,21 +14,27 @@ const Legend = ({

Legend

{ - selectedTypes.length > 0 ? - selectedTypes.map(({ type, color, abbrev }, idx) => ( - - - { type } [{abbrev}] - - )) : - - No request types selected. - + selectedTypes.length > 0 + ? selectedTypes.map(({ type, color, abbrev }) => ( + + + { type } + {' '} + [ + {abbrev} + ] + + )) + : ( + + No request types selected. + + ) }
@@ -42,5 +48,5 @@ const mapStateToProps = state => ({ export default connect(mapStateToProps)(Legend); Legend.propTypes = { - requestTypes: PropTypes.object.isRequired, + requestTypes: PropTypes.shape({}).isRequired, }; diff --git a/src/components/Visualizations/NumberOfRequests.jsx b/src/components/Visualizations/NumberOfRequests.jsx index b3fd66b8b..dd84e7c40 100644 --- a/src/components/Visualizations/NumberOfRequests.jsx +++ b/src/components/Visualizations/NumberOfRequests.jsx @@ -21,9 +21,9 @@ const NumberOfRequests = ({ export default NumberOfRequests; NumberOfRequests.propTypes = { - numRequests: PropTypes.number.isRequired, + numRequests: PropTypes.number, }; NumberOfRequests.defaultProps = { - numRequests: 1285203, // until we get data + numRequests: 1285203, // until we get data }; diff --git a/src/components/Visualizations/TimeToClose.jsx b/src/components/Visualizations/TimeToClose.jsx index 8a95d92fd..bd8f17b8f 100644 --- a/src/components/Visualizations/TimeToClose.jsx +++ b/src/components/Visualizations/TimeToClose.jsx @@ -1,23 +1,22 @@ import React from 'react'; import PropTypes from 'proptypes'; import { connect } from 'react-redux'; -import Chart from './Chart'; import { REQUEST_TYPES } from '@components/common/CONSTANTS'; +import Chart from './Chart'; + const TimeToClose = ({ requestTypes, }) => { + // // DATA //// - //// DATA //// - - const randomSeries = (count, min, max) => { - return Array.from({ length: count }) - .map(() => Math.random() * (max - min) + min); - }; + const randomSeries = (count, min, max) => Array.from({ length: count }) + .map(() => Math.random() * (max - min) + min); const dummyData = REQUEST_TYPES.reduce((p, c) => { - p[c.type] = randomSeries(8, 0, 11); - return p; + const acc = p; + acc[c.type] = randomSeries(8, 0, 11); + return acc; }, {}); const selectedTypes = REQUEST_TYPES.filter(el => requestTypes[el.type]); @@ -32,7 +31,7 @@ const TimeToClose = ({ }], }; - //// OPTIONS //// + // // OPTIONS //// const chartOptions = { title: { @@ -64,7 +63,7 @@ const TimeToClose = ({ tooltipDecimals: 1, }; - //// HEIGHT //// + // // HEIGHT //// const chartHeight = numLabels => ( numLabels > 0 @@ -82,7 +81,7 @@ const TimeToClose = ({ /> ); -} +}; const mapStateToProps = state => ({ requestTypes: state.data.requestTypes, @@ -91,5 +90,5 @@ const mapStateToProps = state => ({ export default connect(mapStateToProps)(TimeToClose); TimeToClose.propTypes = { - requestTypes: PropTypes.object.isRequired, + requestTypes: PropTypes.shape({}).isRequired, }; diff --git a/src/components/Visualizations/index.jsx b/src/components/Visualizations/index.jsx index ec85f8ef8..029221dca 100644 --- a/src/components/Visualizations/index.jsx +++ b/src/components/Visualizations/index.jsx @@ -13,13 +13,13 @@ const Visualizations = ({ menuIsOpen, menuActiveTab, }) => { - if (menuActiveTab !== MENU_TABS.VISUALIZATIONS) - return null; + if (menuActiveTab !== MENU_TABS.VISUALIZATIONS) return null; return (
+ 'full-width': !menuIsOpen, + })} + > 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 20195ba3b..4893a9bff 100644 --- a/src/components/main/body/Body.jsx +++ b/src/components/main/body/Body.jsx @@ -1,14 +1,16 @@ import React from 'react'; +import Visualizations from '@components/Visualizations'; import Menu from '../menu/Menu'; import PinMap from '../../PinMap/PinMap'; -import Visualizations from '@components/Visualizations'; 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 0d535e86c..e6a0213cc 100644 --- a/src/components/main/menu/Menu.jsx +++ b/src/components/main/menu/Menu.jsx @@ -1,23 +1,23 @@ /* 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'; -import { toggleMenu, setMenuTab } from '@reducers/ui'; -import { MENU_TABS } from '@components/common/CONSTANTS'; - -// 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 = ({ isOpen, activeTab, @@ -31,7 +31,7 @@ const Menu = ({ MENU_TABS.VISUALIZATIONS, ]; - const handleActiveTab = (tab) => (tab === activeTab ? 'is-active' : ''); + const handleActiveTab = tab => (tab === activeTab ? 'is-active' : ''); return (
@@ -70,7 +70,7 @@ const Menu = ({ }} >
    - {tabs.map((tab) => ( + {tabs.map(tab => (
  • +
@@ -122,12 +123,12 @@ const Menu = ({ const mapStateToProps = state => ({ isOpen: state.ui.menu.isOpen, - activeTab: state.ui.menu.activeTab + activeTab: state.ui.menu.activeTab, }); const mapDispatchToProps = dispatch => ({ - toggleMenu: () => dispatch(toggleMenu()), - setMenuTab: tab => dispatch(setMenuTab(tab)) + toggleMenu: () => dispatch(reduxToggleMenu()), + setMenuTab: tab => dispatch(reduxSetMenuTab(tab)), }); export default connect(mapStateToProps, mapDispatchToProps)(Menu); @@ -135,6 +136,11 @@ export default connect(mapStateToProps, mapDispatchToProps)(Menu); Menu.propTypes = { isOpen: PropTypes.bool.isRequired, activeTab: PropTypes.string.isRequired, - toggleMenu: PropTypes.func.isRequired, - setMenuTab: PropTypes.func.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/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/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/webpack.config.js b/webpack.config.js index 9e592fd5f..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', @@ -15,7 +15,7 @@ module.exports = { '@components': path.resolve(__dirname, 'src/components'), '@reducers': path.resolve(__dirname, 'src/redux/reducers'), '@styles': path.resolve(__dirname, 'src/styles'), - } + }, }, module: { rules: [ @@ -32,8 +32,8 @@ module.exports = { { loader: MiniCssExtractPlugin.loader, options: { - hmr: true - } + hmr: true, + }, }, { loader: 'css-loader',