From 8c04f102289497f09a08bd9c925591eeee3cd2a0 Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Fri, 25 Aug 2017 22:07:35 -0700 Subject: [PATCH 01/14] Basic client-site framework is now in place with WebPack 2 and hot-reloading. --- .babelrc | 6 +++++ app/index.html | 11 +++++++++ app/js/app.js | 12 ++++++++++ app/js/components/SiteList.jsx | 11 +++++++++ package.json | 44 ++++++++++++++++++++++++++++++++++ webpack.config.js | 37 ++++++++++++++++++++++++++++ 6 files changed, 121 insertions(+) create mode 100644 .babelrc create mode 100644 app/index.html create mode 100644 app/js/app.js create mode 100644 app/js/components/SiteList.jsx create mode 100644 package.json create mode 100644 webpack.config.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..2a4a32d --- /dev/null +++ b/.babelrc @@ -0,0 +1,6 @@ +{ + "presets": [ + "es2015", + "react" + ] +} \ No newline at end of file diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..7c49c39 --- /dev/null +++ b/app/index.html @@ -0,0 +1,11 @@ + + + + GSTV Full Stack Coding Exercise + + + +
+ + + \ No newline at end of file diff --git a/app/js/app.js b/app/js/app.js new file mode 100644 index 0000000..88c066e --- /dev/null +++ b/app/js/app.js @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import SiteList from './components/SiteList.jsx'; + +ReactDOM.render( + , + document.getElementById('app') +); + + + +console.log('hello GSTV'); \ No newline at end of file diff --git a/app/js/components/SiteList.jsx b/app/js/components/SiteList.jsx new file mode 100644 index 0000000..bd9cfdd --- /dev/null +++ b/app/js/components/SiteList.jsx @@ -0,0 +1,11 @@ +import React, { Component } from 'react'; + +export default class Menu extends Component { + render() { + return ( +
+ GSTV Site List goes here. +
+ ); + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..d23f8ef --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "full-stack-coding-exercise", + "version": "1.0.0", + "description": "GSTV Coding Excercise", + "main": "index.js", + "scripts": { + "test": "mocha", + "dev": "node ./node_modules/webpack-dev-server/bin/webpack-dev-server.js --hot --inline --history-api-fallback" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/BrianPiere/full-stack-coding-exercise.git" + }, + "keywords": [ + "GSTV" + ], + "author": "Brian Piere", + "license": "ISC", + "bugs": { + "url": "https://github.com/BrianPiere/full-stack-coding-exercise/issues" + }, + "homepage": "https://github.com/BrianPiere/full-stack-coding-exercise#readme", + "dependencies": { + "html-webpack-plugin": "^2.30.1", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-router": "^4.2.0" + }, + "devDependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "eslint": "^4.3.0", + "eslint-config-airbnb": "^15.1.0", + "eslint-plugin-import": "^2.7.0", + "eslint-plugin-jsx-a11y": "^5.1.1", + "eslint-plugin-react": "^7.1.0", + "file-loader": "^0.11.2", + "react-hot-loader": "^1.3.1", + "webpack": "^3.5.5", + "webpack-dev-server": "^2.7.1" + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..fcd111c --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,37 @@ +const path = require('path'); + +var HtmlWebpackPlugin = require('html-webpack-plugin'); + +var HTMLWebpackPluginConfig = new HtmlWebpackPlugin({ + template: __dirname + '/app/index.html', + filename: 'index.html', + inject: 'body' +}) + +module.exports = { + + context: __dirname + "/app", + + entry: { + javascript: "./js/app.js" + }, + + output: { + filename: "app.js", + path: __dirname + "/dist", + }, + + resolve: { + extensions: ['.js', '.jsx', '.json'] + }, + + module: { + loaders: [ + { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, + { test: /\.jsx$/, loader: ['react-hot-loader', 'babel-loader'], exclude: /node_modules/ }, + ] + }, + + plugins: [HTMLWebpackPluginConfig] + +}; \ No newline at end of file From 5294c0a9ee48c01ffd0417d6d09bb9b7dd31486a Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Sat, 26 Aug 2017 13:13:54 -0700 Subject: [PATCH 02/14] Basic routing configured. --- app/js/app.js | 11 +++++----- app/js/components/Application.jsx | 14 ++++++++++++ app/js/components/SiteDetails.jsx | 17 +++++++++++++++ app/js/components/SiteList.jsx | 36 ++++++++++++++++++++++--------- package.json | 2 +- 5 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 app/js/components/Application.jsx create mode 100644 app/js/components/SiteDetails.jsx diff --git a/app/js/app.js b/app/js/app.js index 88c066e..aa218ec 100644 --- a/app/js/app.js +++ b/app/js/app.js @@ -1,12 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import SiteList from './components/SiteList.jsx'; +import Application from './components/Application.jsx'; +import { BrowserRouter } from 'react-router-dom'; ReactDOM.render( - , + + + + , document.getElementById('app') ); - - -console.log('hello GSTV'); \ No newline at end of file diff --git a/app/js/components/Application.jsx b/app/js/components/Application.jsx new file mode 100644 index 0000000..3570647 --- /dev/null +++ b/app/js/components/Application.jsx @@ -0,0 +1,14 @@ +import { Switch, Route } from 'react-router-dom' +import React from 'react' +import SiteList from './SiteList.jsx' +import SiteDetails from './SiteDetails.jsx' + +export default () => ( +
+

GSTV

+ + + + +
+) diff --git a/app/js/components/SiteDetails.jsx b/app/js/components/SiteDetails.jsx new file mode 100644 index 0000000..c9a5969 --- /dev/null +++ b/app/js/components/SiteDetails.jsx @@ -0,0 +1,17 @@ +import React, { Component } from 'react'; + +export default (props) => { + + // const siteObj = getSite(parseInt(props.match.params.id)) + const siteObj = {} + + if (!siteObj) { + return
Sorry, the site does not exist.
+ } + return ( +
+

Site Details #{props.match.params.id}

+
Details
+
+ ) +} diff --git a/app/js/components/SiteList.jsx b/app/js/components/SiteList.jsx index bd9cfdd..a2584f8 100644 --- a/app/js/components/SiteList.jsx +++ b/app/js/components/SiteList.jsx @@ -1,11 +1,27 @@ -import React, { Component } from 'react'; - -export default class Menu extends Component { - render() { - return ( -
- GSTV Site List goes here. -
- ); - } +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +export default class SiteList extends Component { + + render() { + + const siteIds = [12, 23, 232, 501]; + + return ( +
+ Here is the site list. + +
    + { + siteIds.map(id => ( +
  • + Site ID {id} +
  • + )) + } +
+ +
+ ); + } } \ No newline at end of file diff --git a/package.json b/package.json index d23f8ef..51e7251 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "html-webpack-plugin": "^2.30.1", "react": "^15.6.1", "react-dom": "^15.6.1", - "react-router": "^4.2.0" + "react-router-dom": "^4.2.2" }, "devDependencies": { "babel-core": "^6.26.0", From 64b3b7fa296ac0757d18005db1396b8d8eb09c0e Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Sat, 26 Aug 2017 13:17:36 -0700 Subject: [PATCH 03/14] Adding my task list into the repo. --- Brian Task List.txt | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 Brian Task List.txt diff --git a/Brian Task List.txt b/Brian Task List.txt new file mode 100644 index 0000000..6763ec7 --- /dev/null +++ b/Brian Task List.txt @@ -0,0 +1,29 @@ + +[] Stub out REST API +[] Configure server-side framework + [] Install Express + [] Install Mongo + [] Get DB Access working with Promises. +[] Configure client-side framework + [x] Install WebPack DevServer + [x] Install Babel and configure .babelrc + [x] Install React and React-DOM + [x] Create test file for /app/js/app.js + [x] Make sure that running "webpack" (global) will output to DIST. + [x] Install file-loader. + [x] Solve WebPack error: ERROR in Entry module not found: Error: Can't resolve 'file' in '\Projects\full-stack-coding-exercise/app' + [x] Solve issue with "Multiple assets emit to the same filename" + [x] Install html-webpack-plugin instead of using file-loader. + [X] Install ESLint... Use AirBnB's rules: https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb + [x] Install React Hot Reloader + [x] Fix error after insalling Hot Reloader to WebPack: 'import' and 'export' may only appear at the top level (3:0) + [x] Install React-Router + [x] Create 2 basic routes for the SiteList and SiteDetail/:slug: + [] Install Redux + [] Install CSS framework +[] Research CSS Solution. CSS Modules ... Styled Components??? +[x] Get JSX / Babel Package for SublimeText +[] Figure out why Sublime Text is not honoring the .editorconfig IDE settings. + [] Figure out why .sublime_project is not allowing Tabbed spaces. +[x] Setup local repo with a Fork and GitFlow +[x] Go through Readme and clarify any ambiguity. \ No newline at end of file From 50c417af84d97ef677864a79a9d745e4c9663470 Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Sat, 26 Aug 2017 17:54:11 -0700 Subject: [PATCH 04/14] Pollyfilled fetch and Promise using WebPack --- Brian Task List.txt | 5 +++++ package.json | 4 +++- webpack.config.js | 8 +++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Brian Task List.txt b/Brian Task List.txt index 6763ec7..e910177 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -14,13 +14,18 @@ [x] Solve WebPack error: ERROR in Entry module not found: Error: Can't resolve 'file' in '\Projects\full-stack-coding-exercise/app' [x] Solve issue with "Multiple assets emit to the same filename" [x] Install html-webpack-plugin instead of using file-loader. + [] Find out why WebPack 2 doesn't use a "root" for module dependencies. How important is this? [X] Install ESLint... Use AirBnB's rules: https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb [x] Install React Hot Reloader [x] Fix error after insalling Hot Reloader to WebPack: 'import' and 'export' may only appear at the top level (3:0) [x] Install React-Router [x] Create 2 basic routes for the SiteList and SiteDetail/:slug: [] Install Redux + [] Install Normalizr + [] Install Immutable.js [] Install CSS framework + [x] Pollyfill the "fetch" and "promise" using WebPack, doesn't look like Babel does this. + [] Verify that Pollyfill worked, even if WebPack compiled OK. [] Research CSS Solution. CSS Modules ... Styled Components??? [x] Get JSX / Babel Package for SublimeText [] Figure out why Sublime Text is not honoring the .editorconfig IDE settings. diff --git a/package.json b/package.json index 51e7251..dbba7a8 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "html-webpack-plugin": "^2.30.1", "react": "^15.6.1", "react-dom": "^15.6.1", - "react-router-dom": "^4.2.2" + "react-redux": "^5.0.6", + "react-router-dom": "^4.2.2", + "redux": "^3.7.2" }, "devDependencies": { "babel-core": "^6.26.0", diff --git a/webpack.config.js b/webpack.config.js index fcd111c..32b1a08 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,6 +1,7 @@ const path = require('path'); var HtmlWebpackPlugin = require('html-webpack-plugin'); +var webpack = require('webpack'); var HTMLWebpackPluginConfig = new HtmlWebpackPlugin({ template: __dirname + '/app/index.html', @@ -8,6 +9,11 @@ var HTMLWebpackPluginConfig = new HtmlWebpackPlugin({ inject: 'body' }) +var PollyFillWebPackPlugin = new webpack.ProvidePlugin({ + 'Promise': 'es6-promise', + 'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch' +}) + module.exports = { context: __dirname + "/app", @@ -32,6 +38,6 @@ module.exports = { ] }, - plugins: [HTMLWebpackPluginConfig] + plugins: [HTMLWebpackPluginConfig, PollyFillWebPackPlugin] }; \ No newline at end of file From 739ce9d7e045478832df5a00fa3e35fda048ef9a Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Sat, 26 Aug 2017 20:23:29 -0700 Subject: [PATCH 05/14] Have a basic Express server working with a connection to a MongoDB instance in the cloud. Created a setup script to inject default data into Mongo. Also got Redux Provider working with the Router on the Application component. --- Brian Task List.txt | 12 ++++-- app/js/actions/index.js | 9 ++++ app/js/app.js | 20 ++++----- app/js/components/Application.jsx | 26 ++++++++---- app/js/reducers/index.js | 3 ++ package.json | 3 ++ server/dbconnection | 1 + server/rest.js | 70 +++++++++++++++++++++++++++++++ 8 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 app/js/actions/index.js create mode 100644 app/js/reducers/index.js create mode 100644 server/dbconnection create mode 100644 server/rest.js diff --git a/Brian Task List.txt b/Brian Task List.txt index e910177..3330646 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,9 +1,12 @@ [] Stub out REST API [] Configure server-side framework - [] Install Express - [] Install Mongo + [x] Install Express + [x] Install Mongo + [x] Setup a MongoDB provider in the cloud. + [x] Verify connection to Mongo server on startup of Express app. [] Get DB Access working with Promises. + [x] Populate MongoDB with a temp script. [] Configure client-side framework [x] Install WebPack DevServer [x] Install Babel and configure .babelrc @@ -14,18 +17,19 @@ [x] Solve WebPack error: ERROR in Entry module not found: Error: Can't resolve 'file' in '\Projects\full-stack-coding-exercise/app' [x] Solve issue with "Multiple assets emit to the same filename" [x] Install html-webpack-plugin instead of using file-loader. - [] Find out why WebPack 2 doesn't use a "root" for module dependencies. How important is this? + [] Find out why WebPack 2 doesn't use a "root" for module dependencies. How important is this over using relative paths? [X] Install ESLint... Use AirBnB's rules: https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb [x] Install React Hot Reloader [x] Fix error after insalling Hot Reloader to WebPack: 'import' and 'export' may only appear at the top level (3:0) [x] Install React-Router [x] Create 2 basic routes for the SiteList and SiteDetail/:slug: - [] Install Redux + [x] Install Redux [] Install Normalizr [] Install Immutable.js [] Install CSS framework [x] Pollyfill the "fetch" and "promise" using WebPack, doesn't look like Babel does this. [] Verify that Pollyfill worked, even if WebPack compiled OK. + [x] Install "prop-types" [] Research CSS Solution. CSS Modules ... Styled Components??? [x] Get JSX / Babel Package for SublimeText [] Figure out why Sublime Text is not honoring the .editorconfig IDE settings. diff --git a/app/js/actions/index.js b/app/js/actions/index.js new file mode 100644 index 0000000..4340f8f --- /dev/null +++ b/app/js/actions/index.js @@ -0,0 +1,9 @@ +export const ADD_FLAG = 'ADD_FLAG' +export const EDIT_FLAG = 'EDIT_FLAG' +export const UPDATE_FLAG = 'UPDATE_FLAG' +export const CREATE_FLAG = 'CREATE_FLAG' +export const LOAD_SITE_DATA = 'LOAD_SITE_DATA' +export const VIEW_SITE = 'VIEW_SITE' +export const CLOSE_SITE = 'CLOSE_SITE' +export const CONFIRM_DELETE_FLAG = 'CONFIRM_DELETE_FLAG' +export const DELETE_FLAG = 'DELETE_FLAG' \ No newline at end of file diff --git a/app/js/app.js b/app/js/app.js index aa218ec..38b9437 100644 --- a/app/js/app.js +++ b/app/js/app.js @@ -1,13 +1,9 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import Application from './components/Application.jsx'; -import { BrowserRouter } from 'react-router-dom'; +import React from 'react' +import ReactDOM from 'react-dom' +import Application from './components/Application.jsx' +import { createStore } from 'redux' +import mainReducer from './reducers' -ReactDOM.render( - - - - , - document.getElementById('app') -); - +let store = createStore(mainReducer) + +ReactDOM.render(, document.getElementById('app')); diff --git a/app/js/components/Application.jsx b/app/js/components/Application.jsx index 3570647..0b2629b 100644 --- a/app/js/components/Application.jsx +++ b/app/js/components/Application.jsx @@ -1,14 +1,24 @@ import { Switch, Route } from 'react-router-dom' import React from 'react' +import PropTypes from 'prop-types' import SiteList from './SiteList.jsx' import SiteDetails from './SiteDetails.jsx' +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux' -export default () => ( -
-

GSTV

- - - - -
+const Application = ({ store }) => ( + + + + + + + + ) + +Application.propTypes = { + store: PropTypes.object.isRequired +} + +export default Application diff --git a/app/js/reducers/index.js b/app/js/reducers/index.js new file mode 100644 index 0000000..dabd00c --- /dev/null +++ b/app/js/reducers/index.js @@ -0,0 +1,3 @@ +export default function mainReducer(state = {}, action) { + return state; +} \ No newline at end of file diff --git a/package.json b/package.json index dbba7a8..37dcfe2 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,10 @@ }, "homepage": "https://github.com/BrianPiere/full-stack-coding-exercise#readme", "dependencies": { + "express": "^4.15.4", "html-webpack-plugin": "^2.30.1", + "mongodb": "^2.2.31", + "prop-types": "^15.5.10", "react": "^15.6.1", "react-dom": "^15.6.1", "react-redux": "^5.0.6", diff --git a/server/dbconnection b/server/dbconnection new file mode 100644 index 0000000..0e626cf --- /dev/null +++ b/server/dbconnection @@ -0,0 +1 @@ +mongodb://fullstack:PwForFullStackExcersise@ds159033.mlab.com:59033/fullstackexcersize \ No newline at end of file diff --git a/server/rest.js b/server/rest.js new file mode 100644 index 0000000..59d877e --- /dev/null +++ b/server/rest.js @@ -0,0 +1,70 @@ +const express = require('express') +const fs = require('fs') +const app = express() +const MongoClient = require('mongodb').MongoClient + +var db + +// A little forethought for security. +var dbConnectionStr = fs.readFileSync(__dirname+'/dbconnection', 'utf8') + +// Don't start the REST server until verifying the DB connection. +MongoClient.connect(dbConnectionStr, (err, database) => { + + if (err) return console.log(err) + + db = database + + app.listen(3000, () => { + console.log('listening on 3000') + }) +}) + +app.get('/', function (req, res) { + res.send('Hello World!') +}) + +app.get('/populate_sites_collection', (req, res) => { + + var initialSiteCollection = [ + { name: "Site 1", flags:[] }, + { name: "Site 2", flags:[] }, + { name: "Site 3", flags:[] }, + { name: "Site 4", flags:[] }, + { name: "Site 5", flags:[] }, + { name: "Site 6", flags:[] }, + { name: "Site 7", flags:[] }, + { name: "Site 8", flags:[] }, + { name: "Site 9", flags:[] }, + { name: "Site 10", flags:[] }, + { name: "Site 11", flags:[] }, + { name: "Site 12", flags:[] }, + { name: "Site 13", flags:[] }, + { name: "Site 14", flags:[] }, + { name: "Site 15", flags:[] }, + { name: "Site 16", flags:[] }, + { name: "Site 17", flags:[] }, + { name: "Site 18", flags:[] }, + { name: "Site 19", flags:[] }, + { name: "Site 20", flags:[] }, + { name: "Site 21", flags:[] }, + { name: "Site 22", flags:[] }, + { name: "Site 23", flags:[] }, + { name: "Site 24", flags:[] } + ] + + db.collection('sites').remove({}, (err, result) => { + + if (err) return console.log("Unable to Drop Collection", err) + + db.collection('sites').insertMany(initialSiteCollection, (err, result) => { + + if (err) return console.log("Unable to insert collection", err) + + console.log('Sites Collection saved to MongoDB') + + res.send('Collection Saved') + }) + }) + +}) \ No newline at end of file From 158194519ca49e2aa935651bef46f540852656a4 Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Sat, 26 Aug 2017 23:12:14 -0700 Subject: [PATCH 06/14] The REST service should be complete. However I still haven't tested the PUT route. It also needs to be Unit Tested. --- Brian Task List.txt | 26 +++++++-- server/rest.js | 130 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 122 insertions(+), 34 deletions(-) diff --git a/Brian Task List.txt b/Brian Task List.txt index 3330646..68435f4 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,12 +1,28 @@ -[] Stub out REST API +[] Create REST API + [x] Store DB credentials within an ENV variable or maybe a flat file, just to demonstrate a consideration for security. + [] Get DB Access working with Promises. + [x] Populate MongoDB with a temp script. + [x] Figure out why I can't perform a "findOne()" by _id. + [x] Create a GET route for /sites + [x] Create a GET route for /sites/:id which returns a single Site document. + [x] Create a PUT route for /sites/:id which updates the single Site document. + [x] Verify that the document exists first, no upserting. + [x] Validate contents of the Site document. + [] Setup Mongoose to validate the Site Update route. + [] Unit test with Mocha/Chai + [x] Research Mockgoose. It looks like it requires MongoDB to be installed locally. + [] Configure an ENV variable within Unit Test and adapt REST service. + [] Since MongoDB is in Cloud, setup another "testDB" where the default data script can be loaded each time. + [] Modify the "defaultData" method to insert addtional "flags" within the array on each "site" document. + [] Configure a consumer contract and Pact broker for Microservices + [] Configure server-side framework [x] Install Express [x] Install Mongo - [x] Setup a MongoDB provider in the cloud. + [x] Sign up for a MongoDB provider in the cloud. [x] Verify connection to Mongo server on startup of Express app. - [] Get DB Access working with Promises. - [x] Populate MongoDB with a temp script. + [] Configure client-side framework [x] Install WebPack DevServer [x] Install Babel and configure .babelrc @@ -30,9 +46,11 @@ [x] Pollyfill the "fetch" and "promise" using WebPack, doesn't look like Babel does this. [] Verify that Pollyfill worked, even if WebPack compiled OK. [x] Install "prop-types" + [] Research CSS Solution. CSS Modules ... Styled Components??? [x] Get JSX / Babel Package for SublimeText [] Figure out why Sublime Text is not honoring the .editorconfig IDE settings. [] Figure out why .sublime_project is not allowing Tabbed spaces. + [x] Setup local repo with a Fork and GitFlow [x] Go through Readme and clarify any ambiguity. \ No newline at end of file diff --git a/server/rest.js b/server/rest.js index 59d877e..de687ee 100644 --- a/server/rest.js +++ b/server/rest.js @@ -1,12 +1,15 @@ +"use strict" + const express = require('express') const fs = require('fs') const app = express() const MongoClient = require('mongodb').MongoClient +const MongoObjectId = require('mongodb').ObjectID -var db +let db -// A little forethought for security. -var dbConnectionStr = fs.readFileSync(__dirname+'/dbconnection', 'utf8') +// Sensitive data like this should come from ENV variables in a real-world application. +let dbConnectionStr = fs.readFileSync(__dirname+'/dbconnection', 'utf8') // Don't start the REST server until verifying the DB connection. MongoClient.connect(dbConnectionStr, (err, database) => { @@ -21,37 +24,104 @@ MongoClient.connect(dbConnectionStr, (err, database) => { }) app.get('/', function (req, res) { - res.send('Hello World!') + res.send("Look Maw, I'm on GSTV") +}) + +// Returns a list of all Site Names and ID's +app.get('/sites', function (req, res) { + + // Only pull out the ID and the name for the list of sites. + // Let a sub-path download the full "site" document. + db.collection('sites').find({}, {_id:1, name:1}).toArray((err, result) => { + + if (err) { + console.log("Unable query the sites Collection", err) + res.send({error: "Database error."}); + return; + } + + res.send(result); + }) +}) + +// Returns a single Site document with any embedded flags. +app.get('/sites/:id', function (req, res) { + + let siteId = MongoObjectId(req.params.id); + + db.collection('sites').findOne({_id: siteId}, (err, result) => { + + if (err) { + console.log(`Unable get the Site document for ID: ${siteId}`, err) + res.send({error: "Database error."}); + return; + } + + if(!result){ + res.status(404); + res.send({error: "Cannot find a matching document."}); + return; + } + + res.send(result); + }) +}) + +// Updates the Site ID with the given Site document. +app.put('/sites/:id', function (req, res) { + + let siteId = MongoObjectId(req.params.id); + let siteDocument + + try{ + siteDocument = JSON.parse(req.body) + } + catch(e){ + console.log(`Unable parse the JSON Site document for updating: ${siteId}`, err) + res.send({error: "Unable to parse JSON body."}); + return; + } + + // Ideally Mongoose should be used to validate the Site Document schema, including all of the possible attributes within the 'flags' array. + if(!Array.isArray(siteDocument.flags)) + return res.send({error: "Cannot update the Site document. The 'flags' property must be of type array."}); + + // This API shouldn't let callers modify the Site name or _id. + let flagsPropertyOnly = Object.assign({}, {"flags":siteDocument.flags}) + + db.collection('sites').updateOne({_id: siteId}, {$set:JSON.stringify(flagsPropertyOnly)}, {safe:true}, (err, result) => { + + if (err) { + console.log(`Unable update the Site document for ID: ${siteId}`, err) + res.send({error: "Database error."}); + return; + } + + if(result !== 1){ + console.log(`Unable update the Site document for ID: ${siteId}. Mongo returned with an invalid result: ${result}`) + res.status(404); + res.send({error: "The update operation failed. The ID is likely invalid."}); + return; + } + + res.send(result); + }) }) app.get('/populate_sites_collection', (req, res) => { - var initialSiteCollection = [ - { name: "Site 1", flags:[] }, - { name: "Site 2", flags:[] }, - { name: "Site 3", flags:[] }, - { name: "Site 4", flags:[] }, - { name: "Site 5", flags:[] }, - { name: "Site 6", flags:[] }, - { name: "Site 7", flags:[] }, - { name: "Site 8", flags:[] }, - { name: "Site 9", flags:[] }, - { name: "Site 10", flags:[] }, - { name: "Site 11", flags:[] }, - { name: "Site 12", flags:[] }, - { name: "Site 13", flags:[] }, - { name: "Site 14", flags:[] }, - { name: "Site 15", flags:[] }, - { name: "Site 16", flags:[] }, - { name: "Site 17", flags:[] }, - { name: "Site 18", flags:[] }, - { name: "Site 19", flags:[] }, - { name: "Site 20", flags:[] }, - { name: "Site 21", flags:[] }, - { name: "Site 22", flags:[] }, - { name: "Site 23", flags:[] }, - { name: "Site 24", flags:[] } - ] + let disabled = true; + let numberOfDefaultRecords = 24; + + if(disabled){ + res.send('This route has been disabled to prevent accidental data loss. It will drop the collection and insert default records when enabled.') + return; + } + + let initialSiteCollection = []; + + for(let i=1; i<=numberOfDefaultRecords; i++) + initialSiteCollection.push( { name: "Site Name "+i, flags:[] } ) db.collection('sites').remove({}, (err, result) => { From 3ecc78ccf268f11bca26d1bd51b27f5daa096680 Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Sun, 27 Aug 2017 23:49:40 -0700 Subject: [PATCH 07/14] Solved some tough WebPack issues. Finally got the Redux store wired into the API with a thunk for the Site List. --- Brian Task List.txt | 23 +++- app/js/actions/actionObjects.js | 73 ++++++++++ app/js/actions/actionTypes.js | 18 +++ app/js/actions/index.js | 9 -- app/js/actions/thunks.js | 171 ++++++++++++++++++++++++ app/js/app.js | 6 +- app/js/components/Application.jsx | 4 +- app/js/components/SiteList.jsx | 57 +++++--- app/js/containers/SiteListContainer.jsx | 34 +++++ app/js/lib/constants.js | 4 + app/js/reducers/index.js | 31 ++++- package.json | 6 +- server/rest.js | 7 + webpack.config.js | 5 +- 14 files changed, 407 insertions(+), 41 deletions(-) create mode 100644 app/js/actions/actionObjects.js create mode 100644 app/js/actions/actionTypes.js delete mode 100644 app/js/actions/index.js create mode 100644 app/js/actions/thunks.js create mode 100644 app/js/containers/SiteListContainer.jsx create mode 100644 app/js/lib/constants.js diff --git a/Brian Task List.txt b/Brian Task List.txt index 68435f4..7f137e7 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,4 +1,23 @@ +[] Find out why the ES7 "rest" operators is reporting a syntax error in the browser. Chrome works with the same syntax natively. Maybe it's an ESlint thing? +[] Confirm that WebPack will strip out console.log() statements in production. +[x] Solve Double-load problem on SiteListContainer component. + [x] Figure out why MapStateToProps is not reflecting the state returned from the reducer in the Container method. + [x] Explain why there seems to be 2 separate Redux stores. + [x] Find out why the entry page (/app/js/app.js) is executed twice. +[x] Add CORS support for the REST service. +[x] Fix error that started showing up: Module not found: Error: Can't resolve 'whatwg-fetch' in ... + [x] I had to take out the Pollyfill I created for "fetch" in the WebPack config. Maybe I need to import another library like Axios or manually pollyfill? +[x] Create a Container component for the SiteList that will take care of loading the data from the API if the state is empty. +[] Setup Decorators to validate function inputs, like TypeScript or PropTypes. +[x] Stub out actions with basic Thunk workflow. + [x] Decide on convention for Action types. SFA + [x] How to deal with Errors on PUT "SiteData" which has a SiteID associated with the Payload. + [x] Create Thunk Aysnc Actions for GetSiteList, GetSiteData(SiteID), SaveSiteData(siteId) +[x] Decide on a process for loading data from the server and saving mutations. +[] Design shape of reducers +[] Find a way to keep the "key" entries unique on components within Site Details view. +[] Figure out how to use Nomalizr with "flag" entries. They don't have unique ID's on their own. [] Create REST API [x] Store DB credentials within an ENV variable or maybe a flat file, just to demonstrate a consideration for security. [] Get DB Access working with Promises. @@ -17,7 +36,7 @@ [] Modify the "defaultData" method to insert addtional "flags" within the array on each "site" document. [] Configure a consumer contract and Pact broker for Microservices -[] Configure server-side framework +[x] Configure server-side framework [x] Install Express [x] Install Mongo [x] Sign up for a MongoDB provider in the cloud. @@ -43,6 +62,8 @@ [] Install Normalizr [] Install Immutable.js [] Install CSS framework + [x] Install React-Thunk + [] Install React-Promises [x] Pollyfill the "fetch" and "promise" using WebPack, doesn't look like Babel does this. [] Verify that Pollyfill worked, even if WebPack compiled OK. [x] Install "prop-types" diff --git a/app/js/actions/actionObjects.js b/app/js/actions/actionObjects.js new file mode 100644 index 0000000..c75d9f4 --- /dev/null +++ b/app/js/actions/actionObjects.js @@ -0,0 +1,73 @@ +import * as actionTypes from './actionTypes.js' + + +export function confirmDeleteShow(siteId, flagIndex) { + return { + type: actionTypes.CONFIRM_DELETE_FLAG, + payload: { siteId, flagIndex, show:true } + } +} + +export function confirmDeleteHide(siteId, flagIndex) { + return { + type: actionTypes.CONFIRM_DELETE_FLAG, + payload: { siteId, flagIndex, show:false } + } +} + +export function viewSite(siteId) { + return { + type: actionTypes.VIEW_SITE, + payload: { siteId, show:true } + } +} + +export function closeSite(siteId) { + return { + type: actionTypes.VIEW_SITE, + payload: { siteId, show:false } + } +} + +export function editFlag(siteId, flagIndex) { + return { + type: actionTypes.EDIT_FLAG, + payload: { siteId, flagIndex, show:true } + } +} + +export function editFlagCancel(siteId, flagIndex) { + return { + type: actionTypes.EDIT_FLAG, + payload: { siteId, flagIndex, show:false } + } +} + +export function updateFlag(siteId, flagIndex, flagObj) { + return { + type: actionTypes.UPDATE_FLAG, + payload: { siteId, flagIndex, flagObj } + } +} + +export function addFlag(siteId) { + return { + type: actionTypes.ADD_FLAG, + payload: { siteId, show:true } + } +} + +export function addFlagCancel(siteId) { + return { + type: actionTypes.ADD_FLAG, + payload: { siteId, show:false } + } +} + +export function createNewFlag(siteId, flagObj) { + return { + type: actionTypes.CREATE_FLAG, + payload: { siteId, flagObj } + } +} + diff --git a/app/js/actions/actionTypes.js b/app/js/actions/actionTypes.js new file mode 100644 index 0000000..778845c --- /dev/null +++ b/app/js/actions/actionTypes.js @@ -0,0 +1,18 @@ +export const EDIT_FLAG = 'EDIT_FLAG' +export const UPDATE_FLAG = 'UPDATE_FLAG' + +export const ADD_FLAG = 'ADD_FLAG' +export const CREATE_FLAG = 'CREATE_FLAG' + +export const CONFIRM_DELETE_FLAG = 'CONFIRM_DELETE_FLAG' + +export const SITE_DATA_LOADING = 'SITE_DATA_LOADING' +export const SITE_DATA_RESPONSE = 'SITE_DATA_RESPONSE' + +export const SITE_DATA_SAVE_SENDING = 'SITE_DATA_SAVE_SENDING' +export const SITE_DATA_SAVE_RESPONSE = 'SITE_DATA_SAVE_RESPONSE' + +export const SITES_LIST_LOADING = 'SITES_LIST_LOADING' +export const SITES_LIST_RESPONSE = 'SITES_LIST_RESPONSE' + +export const VIEW_SITE = 'VIEW_SITE' diff --git a/app/js/actions/index.js b/app/js/actions/index.js deleted file mode 100644 index 4340f8f..0000000 --- a/app/js/actions/index.js +++ /dev/null @@ -1,9 +0,0 @@ -export const ADD_FLAG = 'ADD_FLAG' -export const EDIT_FLAG = 'EDIT_FLAG' -export const UPDATE_FLAG = 'UPDATE_FLAG' -export const CREATE_FLAG = 'CREATE_FLAG' -export const LOAD_SITE_DATA = 'LOAD_SITE_DATA' -export const VIEW_SITE = 'VIEW_SITE' -export const CLOSE_SITE = 'CLOSE_SITE' -export const CONFIRM_DELETE_FLAG = 'CONFIRM_DELETE_FLAG' -export const DELETE_FLAG = 'DELETE_FLAG' \ No newline at end of file diff --git a/app/js/actions/thunks.js b/app/js/actions/thunks.js new file mode 100644 index 0000000..5f5130b --- /dev/null +++ b/app/js/actions/thunks.js @@ -0,0 +1,171 @@ +import {URL_SITES_LIST} from '../lib/constants.js' +import * as actionTypes from './actionTypes.js' + +// Setup instructions from https://www.npmjs.com/package/fetch-everywhere +// Provides a pollyfill for "fetch" within global scope. +require('es6-promise').polyfill(); +require('fetch-everywhere'); + + +// The "sites list" calls a REST service which returns an array of Site ID's and Site Names (without any Site data). +export function fetchSitesList() { + + return (dispatch) => { + + dispatch(sitesListIsLoading(true)); + + fetch(URL_SITES_LIST) + .then((response) => { + + if (!response.ok) + throw new Error("Error loading sites list: " + response.statusText) + + return response; + }) + .then(response => response.json()) + .then(responseObj => dispatch(sitesListDataSuccess(responseObj)) ) + .catch(error => dispatch(sitesListDataError(error))) + }; +} + +function sitesListIsLoading(bool) { + + return { + type: actionTypes.SITES_LIST_LOADING, + payload: bool + } +} + +function sitesListDataSuccess(sites) { + + return { + type: actionTypes.SITES_LIST_RESPONSE, + payload: sites, + error: false + } +} + +function sitesListDataError(error) { + + console.log("Error fetching data for sites list:", error) + + return { + type: actionTypes.SITES_LIST_RESPONSE, + payload: error, + error: true + } +} + + +// The "site data" is comes from another REST call to populate the flags for a given Site ID. +export function fetchSiteData(siteId) { + + return (dispatch) => { + + dispatch(siteDataIsLoading(siteId, true)); + + fetch(URL_SITES_LIST + "/" + siteId) + .then((response) => { + + dispatch(siteDataIsLoading(siteId, false)); + + if (!response.ok) + throw new Error(`Error loading site data for ID ${siteId}: ${response.statusText}`) + + return response; + }) + .then(response => response.json()) + .then(responseObj => dispatch(siteDataSuccess(siteId, responseObj))) + .catch(error => dispatch(siteDataError(siteId, error))) + }; +} + +function siteDataIsLoading(siteId, isLoading) { + + return { + type: actionTypes.SITE_DATA_LOADING, + payload: { siteId, isLoading } + } +} + +function siteDataSuccess(siteId, siteObj) { + + return { + type: actionTypes.SITE_DATA_RESPONSE, + payload: { siteId, siteObj }, + error: false + } +} + +function siteDataError(siteId, error) { + + console && console.log && console.log("Error fetching data for site data:", error) + + return { + type: actionTypes.SITE_DATA_RESPONSE, + payload: { siteId, error }, + error: true + } +} + + +// Saves the Site Data back to the server. +export function saveSiteData(siteId, siteObj) { + + if(!siteObj || !Array.isArray(siteObj.flags)) + throw new Error("Error with saveSiteData action creator. The given Site Object should contain an array of Flag objects.") + + return (dispatch) => { + + dispatch(siteDataIsSaving(siteId, true)); + + fetch(URL_SITES_LIST + "/" + siteId, { + method: 'PUT', + data: { + flags: siteObj.flags + } + }) + .then((response) => { + + dispatch(siteDataIsSaving(siteId, false)) + + if (!response.ok) + throw new Error(`Error loading site data for ID ${siteId}: ${response.statusText}`) + + return response + }) + .then(response => dispatch(siteDataSaveSuccess(siteId))) + .catch(error => dispatch(siteDataSaveError(siteId, error))) + }; +} + +function siteDataIsSaving(siteId, isSaving) { + + return { + type: actionTypes.SITE_DATA_SAVE_SENDING, + payload: { siteId, isSaving } + } +} + +function siteDataSaveSuccess(siteId) { + + return { + type: actionTypes.SITE_DATA_SAVE_RESPONSE, + payload: { siteId }, + error: false + } +} + +// Normally the Payload should be an Error object for Flux Standard Actions. +// However for this case it's necessary to know the SiteID which failed within the dispatch. +// In case of an error the Payload will contain the Error object on a sub-key. +function siteDataSaveError(siteId, error) { + + console && console.log && console.log("Error fetching data for site data:", error) + + return { + type: actionTypes.SITE_DATA_SAVE_RESPONSE, + payload: { siteId, error }, + error: true + } +} diff --git a/app/js/app.js b/app/js/app.js index 38b9437..b369a47 100644 --- a/app/js/app.js +++ b/app/js/app.js @@ -1,9 +1,11 @@ import React from 'react' import ReactDOM from 'react-dom' import Application from './components/Application.jsx' -import { createStore } from 'redux' +import { createStore, applyMiddleware } from 'redux' +import thunk from 'redux-thunk'; import mainReducer from './reducers' +import logger from 'redux-logger' -let store = createStore(mainReducer) +let store = createStore(mainReducer, undefined, applyMiddleware(thunk, logger)) ReactDOM.render(, document.getElementById('app')); diff --git a/app/js/components/Application.jsx b/app/js/components/Application.jsx index 0b2629b..5a8aae1 100644 --- a/app/js/components/Application.jsx +++ b/app/js/components/Application.jsx @@ -1,7 +1,7 @@ import { Switch, Route } from 'react-router-dom' import React from 'react' import PropTypes from 'prop-types' -import SiteList from './SiteList.jsx' +import SiteListContainer from '../containers/SiteListContainer' import SiteDetails from './SiteDetails.jsx' import { BrowserRouter } from 'react-router-dom'; import { Provider } from 'react-redux' @@ -10,7 +10,7 @@ const Application = ({ store }) => ( - + diff --git a/app/js/components/SiteList.jsx b/app/js/components/SiteList.jsx index a2584f8..699eec0 100644 --- a/app/js/components/SiteList.jsx +++ b/app/js/components/SiteList.jsx @@ -1,27 +1,44 @@ import React, { Component } from 'react' import { Link } from 'react-router-dom' -export default class SiteList extends Component { - render() { +export default (props) => { - const siteIds = [12, 23, 232, 501]; + let { sites } = props + if(props.siteListLoadingError) return ( -
- Here is the site list. - -
    - { - siteIds.map(id => ( -
  • - Site ID {id} -
  • - )) - } -
- -
- ); - } -} \ No newline at end of file +
There was an error loading the Site List.
+ ) + + if(props.siteListIsLoading) + return ( +
... please wait ...
+ ) + + if(!sites.length) + return ( +
There are no available sites at this time.
+ ) + + return ( +
+ + Site List + +
    + { + sites.map(siteObj => ( + +
  • + {siteObj.name} +
  • + )) + } +
+ +
+ ) + +} + diff --git a/app/js/containers/SiteListContainer.jsx b/app/js/containers/SiteListContainer.jsx new file mode 100644 index 0000000..26e13a2 --- /dev/null +++ b/app/js/containers/SiteListContainer.jsx @@ -0,0 +1,34 @@ +import { connect } from 'react-redux' +import SiteList from '../components/SiteList' +import React, { Component } from 'react'; +import { fetchSitesList } from '../actions/thunks' + + +class SiteListContainer extends Component { + + componentDidMount() { + + let { dispatch, sites, siteListIsLoading } = this.props + + if(siteListIsLoading){ + console.log("The Site List is already loading, skipping."); + return; + } + + if(!sites.length) + dispatch(fetchSitesList()) + } + + render() { + + return + } +} + +export default connect(state => ( + { + sites: state.sites, + siteListIsLoading: state.siteListIsLoading, + siteListLoadingError: state.siteListLoadingError + } + ))(SiteListContainer) \ No newline at end of file diff --git a/app/js/lib/constants.js b/app/js/lib/constants.js new file mode 100644 index 0000000..2cdfb4a --- /dev/null +++ b/app/js/lib/constants.js @@ -0,0 +1,4 @@ +// Ideally this would come from ENV variables. +const restBasePath = "http://localhost:3000" + +export const URL_SITES_LIST = restBasePath + "/sites" \ No newline at end of file diff --git a/app/js/reducers/index.js b/app/js/reducers/index.js index dabd00c..3bb4e5a 100644 --- a/app/js/reducers/index.js +++ b/app/js/reducers/index.js @@ -1,3 +1,28 @@ -export default function mainReducer(state = {}, action) { - return state; -} \ No newline at end of file +import * as actionTypes from '../actions/actionTypes' + +export default function mainReducer( + state = { + siteListIsLoading:false, + siteListLoadingError:false, + sites:[] + }, + action) { + + switch (action.type) { + + case actionTypes.SITES_LIST_LOADING: + + return Object.assign({}, state, {siteListIsLoading: action.payload }); + + case actionTypes.SITES_LIST_RESPONSE: + + if(action.error) + return Object.assign({}, state, {siteListLoadingError: true, siteListIsLoading: false, sites:[] }); + else + return Object.assign({}, state, {siteListLoadingError: false, siteListIsLoading: false, sites: action.payload }); + + default: + return state; + } +} + diff --git a/package.json b/package.json index 37dcfe2..28fd424 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ }, "homepage": "https://github.com/BrianPiere/full-stack-coding-exercise#readme", "dependencies": { + "es6-promise": "^4.1.1", "express": "^4.15.4", + "fetch-everywhere": "^1.0.5", "html-webpack-plugin": "^2.30.1", "mongodb": "^2.2.31", "prop-types": "^15.5.10", @@ -29,7 +31,9 @@ "react-dom": "^15.6.1", "react-redux": "^5.0.6", "react-router-dom": "^4.2.2", - "redux": "^3.7.2" + "redux": "^3.7.2", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.2.0" }, "devDependencies": { "babel-core": "^6.26.0", diff --git a/server/rest.js b/server/rest.js index de687ee..3b20b29 100644 --- a/server/rest.js +++ b/server/rest.js @@ -6,6 +6,13 @@ const app = express() const MongoClient = require('mongodb').MongoClient const MongoObjectId = require('mongodb').ObjectID + +app.all('/*', function(req, res, next) { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "X-Requested-With"); + next(); +}); + let db // Sensitive data like this should come from ENV variables in a real-world application. diff --git a/webpack.config.js b/webpack.config.js index 32b1a08..924fb89 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,12 +6,11 @@ var webpack = require('webpack'); var HTMLWebpackPluginConfig = new HtmlWebpackPlugin({ template: __dirname + '/app/index.html', filename: 'index.html', - inject: 'body' + inject: false }) var PollyFillWebPackPlugin = new webpack.ProvidePlugin({ - 'Promise': 'es6-promise', - 'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch' + 'Promise': 'es6-promise' }) module.exports = { From 32a047cdd4e7083814dfa11eccd8e32147783650 Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Mon, 28 Aug 2017 17:46:37 -0700 Subject: [PATCH 08/14] Now the Site details will load (if needed) from the server, otherwise from cache. --- Brian Task List.txt | 12 +- app/js/actions/actionObjects.js | 8 +- app/js/actions/actionTypes.js | 3 +- app/js/actions/thunks.js | 4 - app/js/app.js | 1 + app/js/components/Application.jsx | 5 +- app/js/components/SiteDetails.jsx | 33 ++++-- app/js/components/SiteList.jsx | 4 +- app/js/containers/SiteDetailsContainer.jsx | 65 ++++++++++ app/js/containers/SiteListContainer.jsx | 21 ++-- app/js/reducers/index.js | 132 +++++++++++++++++++-- app/js/selectors/index.js | 102 ++++++++++++++++ package.json | 3 +- 13 files changed, 351 insertions(+), 42 deletions(-) create mode 100644 app/js/containers/SiteDetailsContainer.jsx create mode 100644 app/js/selectors/index.js diff --git a/Brian Task List.txt b/Brian Task List.txt index 7f137e7..839a476 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,4 +1,14 @@ +[x] Find out why Selector for getSelectedSiteIsLoaded is returning stale data from the state. +[x] Solve error connecting to MongoDB. Just happened out of the blue: "Topology was destroyed" +[x] Solve problem of Selected SiteID not updating from change to Router parameters. +[] Add a validator module for checking the shape of arbitrary objects in the applicaiton. +[] Currently the SitesList is going to re-render for every change to a child component. Consider making the SiteList just an array of SiteID,Name within a new selector to keep the component stable. +[x] Find out with the SiteDetailsContainer is not re-rendering after a change to the State. +[] Refactor codebase to get rid of inconsistent use of "siteList..." versus "sitesList". +[x] Store the selected Site ID on the State after a Route change so that the Site Details can be memoized in various selectors. + [x] Move the info about the SiteList, such as error, inProgress, etc. into its own Key on the state and combine reducers. +[x] Refactor the dispatch calls for "isLoading(false)" on SiteDeails Load/Save. The Reducers can infer when the action for the Response or Error is given. [] Find out why the ES7 "rest" operators is reporting a syntax error in the browser. Chrome works with the same syntax natively. Maybe it's an ESlint thing? [] Confirm that WebPack will strip out console.log() statements in production. [x] Solve Double-load problem on SiteListContainer component. @@ -74,4 +84,4 @@ [] Figure out why .sublime_project is not allowing Tabbed spaces. [x] Setup local repo with a Fork and GitFlow -[x] Go through Readme and clarify any ambiguity. \ No newline at end of file +[x] Go through Readme and clarify any ambiguity. exercise \ No newline at end of file diff --git a/app/js/actions/actionObjects.js b/app/js/actions/actionObjects.js index c75d9f4..1697987 100644 --- a/app/js/actions/actionObjects.js +++ b/app/js/actions/actionObjects.js @@ -15,16 +15,16 @@ export function confirmDeleteHide(siteId, flagIndex) { } } -export function viewSite(siteId) { +export function selectSite(siteId) { return { - type: actionTypes.VIEW_SITE, + type: actionTypes.SELECT_SITE, payload: { siteId, show:true } } } -export function closeSite(siteId) { +export function unselectSite(siteId) { return { - type: actionTypes.VIEW_SITE, + type: actionTypes.SELECT_SITE, payload: { siteId, show:false } } } diff --git a/app/js/actions/actionTypes.js b/app/js/actions/actionTypes.js index 778845c..4754d6a 100644 --- a/app/js/actions/actionTypes.js +++ b/app/js/actions/actionTypes.js @@ -15,4 +15,5 @@ export const SITE_DATA_SAVE_RESPONSE = 'SITE_DATA_SAVE_RESPONSE' export const SITES_LIST_LOADING = 'SITES_LIST_LOADING' export const SITES_LIST_RESPONSE = 'SITES_LIST_RESPONSE' -export const VIEW_SITE = 'VIEW_SITE' +export const SELECT_SITE = 'SELECT_SITE' + diff --git a/app/js/actions/thunks.js b/app/js/actions/thunks.js index 5f5130b..498b5c4 100644 --- a/app/js/actions/thunks.js +++ b/app/js/actions/thunks.js @@ -67,8 +67,6 @@ export function fetchSiteData(siteId) { fetch(URL_SITES_LIST + "/" + siteId) .then((response) => { - dispatch(siteDataIsLoading(siteId, false)); - if (!response.ok) throw new Error(`Error loading site data for ID ${siteId}: ${response.statusText}`) @@ -127,8 +125,6 @@ export function saveSiteData(siteId, siteObj) { }) .then((response) => { - dispatch(siteDataIsSaving(siteId, false)) - if (!response.ok) throw new Error(`Error loading site data for ID ${siteId}: ${response.statusText}`) diff --git a/app/js/app.js b/app/js/app.js index b369a47..88d5f83 100644 --- a/app/js/app.js +++ b/app/js/app.js @@ -6,6 +6,7 @@ import thunk from 'redux-thunk'; import mainReducer from './reducers' import logger from 'redux-logger' + let store = createStore(mainReducer, undefined, applyMiddleware(thunk, logger)) ReactDOM.render(, document.getElementById('app')); diff --git a/app/js/components/Application.jsx b/app/js/components/Application.jsx index 5a8aae1..d8e8d1d 100644 --- a/app/js/components/Application.jsx +++ b/app/js/components/Application.jsx @@ -2,16 +2,17 @@ import { Switch, Route } from 'react-router-dom' import React from 'react' import PropTypes from 'prop-types' import SiteListContainer from '../containers/SiteListContainer' -import SiteDetails from './SiteDetails.jsx' +import SiteDetailsContainer from '../containers/SiteDetailsContainer' import { BrowserRouter } from 'react-router-dom'; import { Provider } from 'react-redux' const Application = ({ store }) => ( + - + diff --git a/app/js/components/SiteDetails.jsx b/app/js/components/SiteDetails.jsx index c9a5969..c2ecb85 100644 --- a/app/js/components/SiteDetails.jsx +++ b/app/js/components/SiteDetails.jsx @@ -2,16 +2,27 @@ import React, { Component } from 'react'; export default (props) => { - // const siteObj = getSite(parseInt(props.match.params.id)) - const siteObj = {} + const siteObj = {} - if (!siteObj) { - return
Sorry, the site does not exist.
- } - return ( -
-

Site Details #{props.match.params.id}

-
Details
-
- ) + if(props.hasErrorLoading) + return ( +
There was an error loading the Site Details.
+ ) + + if(props.isLoading) + return ( +
... please wait ...
+ ) + + if(!props.isLoaded) + return ( +
The Site Details haven't been loaded from the server yet.
+ ) + + return ( +
+

Site ID #{props.siteId}, Site Name: {props.siteName}

+
Total Flags {props.siteFlags.length}
+
+ ) } diff --git a/app/js/components/SiteList.jsx b/app/js/components/SiteList.jsx index 699eec0..4b14785 100644 --- a/app/js/components/SiteList.jsx +++ b/app/js/components/SiteList.jsx @@ -6,12 +6,12 @@ export default (props) => { let { sites } = props - if(props.siteListLoadingError) + if(props.sitesListLoadingError) return (
There was an error loading the Site List.
) - if(props.siteListIsLoading) + if(props.sitesListIsLoading) return (
... please wait ...
) diff --git a/app/js/containers/SiteDetailsContainer.jsx b/app/js/containers/SiteDetailsContainer.jsx new file mode 100644 index 0000000..8f747b1 --- /dev/null +++ b/app/js/containers/SiteDetailsContainer.jsx @@ -0,0 +1,65 @@ +import { connect } from 'react-redux' +import SiteDetails from '../components/SiteDetails' +import React, { Component } from 'react'; +import { fetchSiteData } from '../actions/thunks' +import { selectSite } from '../actions/actionObjects' +import { getSelectedSiteId, getSelectedSiteName, getSelectedSiteFlags, getSelectedSiteIsLoading, getSelectedSiteHasError, getSelectedSiteIsLoaded } from '../selectors' + + +class SiteDetailsContainer extends Component { + + componentDidMount() { + this.loadRoutine(); + } + + componentDidUpdate() { + this.loadRoutine(); + } + + loadRoutine() { + + let { dispatch, siteDetailsObj, isLoading, siteId, isLoaded } = this.props + + if(!this.props.match.params.id) + throw new Error("The 'id' must always be present within the SiteDetailsContainer because it's set by the Router."); + + // The SiteID is set by the Router and it should be saved to the Store whenever a change is detected. + if(siteId !== this.props.match.params.id){ + console.log("Selecting the Site ID on the State from the router:", this.props.match.params.id); + dispatch(selectSite(this.props.match.params.id)) + return; + } + + if(isLoading){ + console.log("The Site Details are still loading, once it's down the state will update and re-render this component."); + return; + } + + if(!isLoaded) + dispatch(fetchSiteData(siteId)) + } + + render() { + + const dumbChildProps = { + "siteId":this.props.siteId, + "siteName":this.props.siteName, + "isLoading":this.props.isLoading, + "siteFlags":this.props.siteFlags, + "isLoaded":this.props.isLoaded, + "hasErrorLoading":this.props.hasErrorLoading + } + + return + } +} + +export default connect(state => ( + { + siteId: getSelectedSiteId(state), + siteName: getSelectedSiteName(state), + siteFlags: getSelectedSiteFlags(state), + isLoading: getSelectedSiteIsLoading(state), + isLoaded: getSelectedSiteIsLoaded(state), + hasErrorLoading: getSelectedSiteHasError(state) + }))(SiteDetailsContainer) \ No newline at end of file diff --git a/app/js/containers/SiteListContainer.jsx b/app/js/containers/SiteListContainer.jsx index 26e13a2..bfe9d81 100644 --- a/app/js/containers/SiteListContainer.jsx +++ b/app/js/containers/SiteListContainer.jsx @@ -2,16 +2,17 @@ import { connect } from 'react-redux' import SiteList from '../components/SiteList' import React, { Component } from 'react'; import { fetchSitesList } from '../actions/thunks' +import { getSitesListIsLoading, getSitesListIsLoadingError, getSitesArr } from '../selectors' class SiteListContainer extends Component { componentDidMount() { - let { dispatch, sites, siteListIsLoading } = this.props + const { dispatch, sites, sitesListIsLoading } = this.props - if(siteListIsLoading){ - console.log("The Site List is already loading, skipping."); + if(sitesListIsLoading){ + console.log("The Site List is already loading, no need to load it again."); return; } @@ -21,14 +22,20 @@ class SiteListContainer extends Component { render() { - return + const dumbChildProps = { + "sitesListLoadingError":this.props.sitesListLoadingError, + "sitesListIsLoading":this.props.sitesListIsLoading, + "sites":this.props.sites + } + + return } } export default connect(state => ( { - sites: state.sites, - siteListIsLoading: state.siteListIsLoading, - siteListLoadingError: state.siteListLoadingError + sites: getSitesArr(state), + sitesListIsLoading: getSitesListIsLoading(state), + sitesListLoadingError: getSitesListIsLoadingError(state) } ))(SiteListContainer) \ No newline at end of file diff --git a/app/js/reducers/index.js b/app/js/reducers/index.js index 3bb4e5a..ec1d1f4 100644 --- a/app/js/reducers/index.js +++ b/app/js/reducers/index.js @@ -1,28 +1,142 @@ +import { combineReducers } from 'redux' import * as actionTypes from '../actions/actionTypes' -export default function mainReducer( +export default combineReducers({ + sitesListDetails, + sitesArr +}) + +// This reducer is just concerned about details of the list of Sites. +// For example, if the list is loading from the server, or which one of the ID's is currently selected. +function sitesListDetails ( state = { - siteListIsLoading:false, - siteListLoadingError:false, - sites:[] + sitesListIsLoading:false, + sitesListLoadingError:false, + selectedSiteId:0 }, action) { switch (action.type) { case actionTypes.SITES_LIST_LOADING: - - return Object.assign({}, state, {siteListIsLoading: action.payload }); + + if(typeof action.payload !== "boolean") + throw new Error("Error in reducer for SITES_LIST_LOADING. action.payload should be Boolean"); + + return Object.assign({}, state, {sitesListIsLoading: action.payload }) + + case actionTypes.SELECT_SITE: + + if(action.payload.show) + return Object.assign({}, state, {selectedSiteId: action.payload.siteId }) + else + return Object.assign({}, state, {selectedSiteId: 0 }) case actionTypes.SITES_LIST_RESPONSE: if(action.error) - return Object.assign({}, state, {siteListLoadingError: true, siteListIsLoading: false, sites:[] }); + return Object.assign({}, state, {sitesListLoadingError: true, sitesListIsLoading: false }) else - return Object.assign({}, state, {siteListLoadingError: false, siteListIsLoading: false, sites: action.payload }); + return Object.assign({}, state, {sitesListLoadingError: false, sitesListIsLoading: false }) default: - return state; + return state } } + +// This reducer handles communication for individual Sites and will take care of merging any Site-specific data into the array. +function sitesArr (state = [], action) { + + switch (action.type) { + + case actionTypes.SITES_LIST_RESPONSE: { + + if(action.error){ + return [] + } + else{ + + if(!Array.isArray(action.payload)) + throw new Error("Error in sitesArr reducer. The action payload must be of type array.") + + return action.payload.concat() + } + } + + case actionTypes.SITE_DATA_LOADING: { + + const siteArrCopy = state.concat(); + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.isLoading !== "boolean") + throw new Error("Error in reducer for SITE_DATA_LOADING. action.payload.isLoading should be Boolean"); + + siteObj.isLoading = action.payload.isLoading; + + if(!siteObj.isLoading) + siteObj.isLoaded = false; + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj; + + return siteArrCopy; + } + + case actionTypes.SITE_DATA_RESPONSE: { + + const siteArrCopy = state.concat(); + const siteIdFromPayload = action.payload.siteId + const indexOfSiteInArr = getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy); + + // If there's an error just use the existing record from the current state and set some flags on it. + if(action.error){ + + const existingSiteObjCopy = getSiteObjById(siteIdFromPayload, siteArrCopy); + + existingSiteObjCopy.isLoading = false; + existingSiteObjCopy.isLoaded = false; + existingSiteObjCopy.hasErrorLoading = true; + + siteArrCopy[indexOfSiteInArr] = existingSiteObjCopy; + + return siteArrCopy; + } + + // Otherwise replace the existing Site object on the state array with the object returned from the REST call. + const siteObjFromPayload = action.payload.siteObj + + if(siteIdFromPayload !== siteObjFromPayload._id) + throw new Error("Error in SITE_DATA_RESPONSE reducer. The Payload Site ID does not match the _id key within the REST response."); + + siteObjFromPayload.isLoading = false; + siteObjFromPayload.isLoaded = true; + siteObjFromPayload.hasErrorLoading = false; + + siteArrCopy[indexOfSiteInArr] = siteObjFromPayload; + + return siteArrCopy; + } + default: + return state + } +} + + +const getSiteIndexWithinStateArrayById = (siteId, sitesArr) => { + + const foundIndex = sitesArr.findIndex(siteObj => siteObj._id === siteId) + + if(foundIndex === -1) + throw new Error("Error in Reducer function getSiteIndexWithinStateArrayById. The given Site ID couldn't be found within the Site Array:" + siteId); + + return foundIndex; +} + +const getSiteObjById = (siteId, sitesArr) => { + + const indexOfSite = getSiteIndexWithinStateArrayById(siteId, sitesArr); + + return Object.assign({}, sitesArr[indexOfSite]); +} + diff --git a/app/js/selectors/index.js b/app/js/selectors/index.js new file mode 100644 index 0000000..2a61317 --- /dev/null +++ b/app/js/selectors/index.js @@ -0,0 +1,102 @@ +import { createSelector } from 'reselect' + + +export function getSitesArr(state){ + + if(!Array.isArray(state.sitesArr)) + throw new Error("Error in selector getSitesArr. The state object should contain an array for state.sitesArr[].") + + return state.sitesArr +} + +export function getSiteListDetails(state){ + + if(typeof state.sitesListDetails !== "object") + throw new Error("Error in selector getSiteListDetails. The sitesListDetails should be of type object.") + + return state.sitesListDetails +} + +export const getSelectedSiteId = createSelector( + getSiteListDetails, + getSiteListDetailsObj => getSiteListDetailsObj.selectedSiteId +) + +export const getSitesListIsLoading = createSelector( + getSiteListDetails, + getSiteListDetailsObj => getSiteListDetailsObj.sitesListIsLoading +) + +export const getSitesListIsLoadingError = createSelector( + getSiteListDetails, + getSiteListDetailsObj => getSiteListDetailsObj.sitesListLoadingError +) + +export const getSelectedSiteObj = createSelector( + getSelectedSiteId, + getSitesArr, + (selectedSiteId, sitesArr) => { + + let filteredSitesArr = sitesArr.filter(siteObj => siteObj._id === selectedSiteId) + + if(filteredSitesArr.length !== 1) + return null + + return filteredSitesArr[0] + } +) + +export const getSelectedSiteIsLoading = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.isLoading ? true : false + } +) + +export const getSelectedSiteIsLoaded = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.isLoaded ? true : false + } +) + +export const getSelectedSiteHasError = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.hasErrorLoading ? true : false + } +) + +export const getSelectedSiteFlags = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj || !selectedSiteIdObj.flags) + return [] + + return selectedSiteIdObj.flags + } +) + +export const getSelectedSiteName = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return ""; + + return selectedSiteIdObj.name + } +) \ No newline at end of file diff --git a/package.json b/package.json index 28fd424..2dcd6d7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "react-router-dom": "^4.2.2", "redux": "^3.7.2", "redux-logger": "^3.0.6", - "redux-thunk": "^2.2.0" + "redux-thunk": "^2.2.0", + "reselect": "^3.0.1" }, "devDependencies": { "babel-core": "^6.26.0", From c9a46d400daf65883c1ba9e2b30632b0193e8abf Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Tue, 29 Aug 2017 01:10:10 -0700 Subject: [PATCH 09/14] Now it's possible to click into a Site Details view and click on Add New Site Form. The same component will be re-used for Editing, including validation. --- Brian Task List.txt | 15 ++- app/js/actions/actionObjects.js | 6 +- app/js/actions/actionTypes.js | 1 + app/js/components/AddOrEditFlag.jsx | 110 ++++++++++++++++++++ app/js/components/SiteDetails.jsx | 54 ++++++++-- app/js/components/SiteList.jsx | 23 ++++- app/js/containers/AddFlagContainer.jsx | 69 +++++++++++++ app/js/containers/SiteDetailsContainer.jsx | 18 +++- app/js/containers/SiteListContainer.jsx | 11 ++ app/js/lib/constants.js | 13 ++- app/js/reducers/index.js | 111 +++++++++++++-------- app/js/selectors/index.js | 13 ++- 12 files changed, 383 insertions(+), 61 deletions(-) create mode 100644 app/js/components/AddOrEditFlag.jsx create mode 100644 app/js/containers/AddFlagContainer.jsx diff --git a/Brian Task List.txt b/Brian Task List.txt index 839a476..a389c35 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,8 +1,17 @@ - +[] Clean up all of the state properties on the individual Sites array when the SiteDetailList is closed within the Reducer (likely by brute force). For example, if the Add Flag form is open on a view, then it appears open when re-visiting the same site details page. +[x] Fix weird warning that happens when canceling the AddFlag Form: Form submission canceled because the form is not connected +[] Find out why HotReloading doesn't work within a Route (on the Site Details page). +[x] Solve error from AddFlagContainerComponent: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) +[x] Solve infinite loop that started happening after clicking into a SiteDetails with an AddEditRow component. +[] Abstract the Form for Add/Edit flag so that it can be re-used for both types (like a New/Edit registration page). +[x] Ensure that all Components have PropTypes configured. +[] Design process for processing the Flag objects in an array on the Site Details page. + [] Create objects which accept a SiteID and a FlagIndex as a prop. That way there's no need to pass forward/back callbacks and pollute the SiteDetailsContainer. +[x] Come up with a way to un-select the current site by routing ing back to the main page. +[] Refactor the Reducer for SELECT_SITE. It doesn't need to send a flag in the payload now because another action will be used for un-selecting. [x] Find out why Selector for getSelectedSiteIsLoaded is returning stale data from the state. [x] Solve error connecting to MongoDB. Just happened out of the blue: "Topology was destroyed" [x] Solve problem of Selected SiteID not updating from change to Router parameters. -[] Add a validator module for checking the shape of arbitrary objects in the applicaiton. [] Currently the SitesList is going to re-render for every change to a child component. Consider making the SiteList just an array of SiteID,Name within a new selector to keep the component stable. [x] Find out with the SiteDetailsContainer is not re-rendering after a change to the State. [] Refactor codebase to get rid of inconsistent use of "siteList..." versus "sitesList". @@ -19,7 +28,7 @@ [x] Fix error that started showing up: Module not found: Error: Can't resolve 'whatwg-fetch' in ... [x] I had to take out the Pollyfill I created for "fetch" in the WebPack config. Maybe I need to import another library like Axios or manually pollyfill? [x] Create a Container component for the SiteList that will take care of loading the data from the API if the state is empty. -[] Setup Decorators to validate function inputs, like TypeScript or PropTypes. +[] Setup Decorators to validate function inputs, like TypeScript. [x] Stub out actions with basic Thunk workflow. [x] Decide on convention for Action types. SFA [x] How to deal with Errors on PUT "SiteData" which has a SiteID associated with the Payload. diff --git a/app/js/actions/actionObjects.js b/app/js/actions/actionObjects.js index 1697987..66dee1f 100644 --- a/app/js/actions/actionObjects.js +++ b/app/js/actions/actionObjects.js @@ -22,10 +22,10 @@ export function selectSite(siteId) { } } -export function unselectSite(siteId) { +export function unselectActiveSite() { return { - type: actionTypes.SELECT_SITE, - payload: { siteId, show:false } + type: actionTypes.UNSELECT_ACTIVE_SITE, + payload: { } } } diff --git a/app/js/actions/actionTypes.js b/app/js/actions/actionTypes.js index 4754d6a..458663e 100644 --- a/app/js/actions/actionTypes.js +++ b/app/js/actions/actionTypes.js @@ -16,4 +16,5 @@ export const SITES_LIST_LOADING = 'SITES_LIST_LOADING' export const SITES_LIST_RESPONSE = 'SITES_LIST_RESPONSE' export const SELECT_SITE = 'SELECT_SITE' +export const UNSELECT_ACTIVE_SITE = 'UNSELECT_ACTIVE_SITE' diff --git a/app/js/components/AddOrEditFlag.jsx b/app/js/components/AddOrEditFlag.jsx new file mode 100644 index 0000000..535abb2 --- /dev/null +++ b/app/js/components/AddOrEditFlag.jsx @@ -0,0 +1,110 @@ +import { connect } from 'react-redux' +import React, { Component } from 'react'; +import { possibleFlagValues } from '../lib/constants' +import PropTypes from 'prop-types' + + +export default class AddOrEditFlag extends Component { + + constructor(props) { + + super(props); + + this.handleFormSubmit = this.handleFormSubmit.bind(this); + this.handleFlagTypeChange = this.handleFlagTypeChange.bind(this); + this.handleStartDateChange = this.handleStartDateChange.bind(this); + this.handleEndDateChange = this.handleEndDateChange.bind(this); + this.handleCancel = this.handleCancel.bind(this); + + this.state = { + flagType: this.props.defaultFlagType, + startDate: this.props.defaultStartDate, + endDate: this.props.defaultEndDate, + errorMessage: "" + } + } + + // The AddOrEditFlag should call this method with the Flag Object (to be inserted within the flags array) after it passes validation. + handleFormSubmit(event) { + + this.setState({errorMessage: ""}); + + alert("submit form." + JSON.stringify(this.state)); + + this.setState({errorMessage: "New Error"}); + } + + handleCancel(event) { + this.props.onClose(); + } + + handleFlagTypeChange(event) { + this.setState({flagType: event.target.value}); + } + + handleStartDateChange(event) { + this.setState({startDate: event.target.value}); + } + + handleEndDateChange(event) { + this.setState({endDate: event.target.value}); + } + + componentDidMount() { + + } + + render() { + + let listMenuOptions = possibleFlagValues.map((flagType, index) => + + ) + + if(this.props.isAddForm) + listMenuOptions = [].concat(, listMenuOptions); + + const createOrUpdateText = this.props.isAddForm ? "Create New Flag" : "Update Flag" + + let errorMessageBlock = ""; + + if(this.state.errorMessage) + errorMessageBlock =
{this.state.errorMessage}
+ + + return ( +
+ + {errorMessageBlock} + + + + + + + + + +
+ ); + } +} + +AddOrEditFlag.propTypes = { + onSaveFlag: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + defaultFlagType: PropTypes.string.isRequired, + defaultStartDate: PropTypes.string.isRequired, + defaultEndDate: PropTypes.string.isRequired, + isAddForm: PropTypes.bool.isRequired +} diff --git a/app/js/components/SiteDetails.jsx b/app/js/components/SiteDetails.jsx index c2ecb85..2065950 100644 --- a/app/js/components/SiteDetails.jsx +++ b/app/js/components/SiteDetails.jsx @@ -1,6 +1,9 @@ import React, { Component } from 'react'; +import { Link } from 'react-router-dom' +import PropTypes from 'prop-types' +import AddFlagContainer from '../containers/AddFlagContainer' -export default (props) => { +function SiteDetails (props) { const siteObj = {} @@ -19,10 +22,47 @@ export default (props) => {
The Site Details haven't been loaded from the server yet.
) - return ( -
-

Site ID #{props.siteId}, Site Name: {props.siteName}

-
Total Flags {props.siteFlags.length}
-
- ) + var headerTag =

Site ID #{props.siteId}, Site Name: {props.siteName}

+ var closeLink =
Close
+ var addFlagButton =
+ var flagsList = "Flags" + + // var flagsList = this.props.siteFlags.map((flagObj, flagIndex) => + // + // ) + + if(!props.siteFlags.length){ + + return ( +
+ {headerTag} + {closeLink} + {addFlagButton} +
There are no site flags
+
+ ) + } + else{ + + return ( +
+ {headerTag} + {closeLink} + {addFlagButton} +
Total Flags {props.siteFlags.length}
+
{flagsList}
+
+ ) + } } + +SiteDetails.propTypes = { + hasErrorLoading: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + siteId: PropTypes.string.isRequired, + siteName: PropTypes.string.isRequired, + isLoaded: PropTypes.bool.isRequired, + siteFlags: PropTypes.array.isRequired +} + +export default SiteDetails \ No newline at end of file diff --git a/app/js/components/SiteList.jsx b/app/js/components/SiteList.jsx index 4b14785..85c507e 100644 --- a/app/js/components/SiteList.jsx +++ b/app/js/components/SiteList.jsx @@ -1,8 +1,9 @@ import React, { Component } from 'react' import { Link } from 'react-router-dom' +import PropTypes from 'prop-types' -export default (props) => { +function SiteList (props) { let { sites } = props @@ -24,7 +25,7 @@ export default (props) => { return (
- Site List +
Site List
    { @@ -39,6 +40,24 @@ export default (props) => {
) +} + +SiteList.propTypes = { + + sitesListLoadingError: PropTypes.bool.isRequired, + sitesListIsLoading: PropTypes.bool.isRequired, + sites: PropTypes.array.isRequired, + + sites: PropTypes.arrayOf(function(propValue, key) { + + var validateMsgPrefix = "The SiteList component expects an array for the prop name 'sites'. Each element is an object. The element on key: " + key + " is expects an " + + if(!propValue[key]._id || typeof propValue[key]._id !== "string") + return new Error(validateMsgPrefix + " '_id' property of type string."); + if(!propValue[key].name || typeof propValue[key].name !== "string") + return new Error(validateMsgPrefix + " 'name' property of type string."); + }) } +export default SiteList; diff --git a/app/js/containers/AddFlagContainer.jsx b/app/js/containers/AddFlagContainer.jsx new file mode 100644 index 0000000..ad9ea3f --- /dev/null +++ b/app/js/containers/AddFlagContainer.jsx @@ -0,0 +1,69 @@ +import { connect } from 'react-redux' +import SiteList from '../components/SiteList' +import React, { Component } from 'react'; +import { saveSiteData } from '../actions/thunks' +import { addFlag, addFlagCancel } from '../actions/actionObjects' +import { getShowAddFlagFormForSelectedSite, getSelectedSiteId } from '../selectors' +import AddOrEditFlag from '../components/AddOrEditFlag' +import PropTypes from 'prop-types' + + +class AddFlagContainer extends Component { + + constructor(props) { + + super(props); + + this.handleSaveFlag = this.handleSaveFlag.bind(this); + this.handleCloseAddForm = this.handleCloseAddForm.bind(this); + this.handleShowAddForm = this.handleShowAddForm.bind(this); + } + + // The AddOrEditFlag should call this method with the Flag Object (to be inserted within the flags array) after it passes validation. + handleSaveFlag(flagObj) { + + } + + handleCloseAddForm() { + this.props.dispatch(addFlagCancel(this.props.siteId)) + } + + handleShowAddForm() { + this.props.dispatch(addFlag(this.props.siteId)) + } + + componentDidMount() { + + } + + render() { + + if(this.props.showAddFlagForm){ + + // Re-use the same component for Editing and Adding a Flag. + return ( + ) + } + else{ + return + } + } +} + +AddFlagContainer.propTypes = { + siteId: PropTypes.string.isRequired, + showAddFlagForm: PropTypes.bool.isRequired +} + +export default connect(state => ( + { + siteId: getSelectedSiteId(state), + showAddFlagForm: getShowAddFlagFormForSelectedSite(state) + } + ))(AddFlagContainer) \ No newline at end of file diff --git a/app/js/containers/SiteDetailsContainer.jsx b/app/js/containers/SiteDetailsContainer.jsx index 8f747b1..c700005 100644 --- a/app/js/containers/SiteDetailsContainer.jsx +++ b/app/js/containers/SiteDetailsContainer.jsx @@ -3,7 +3,14 @@ import SiteDetails from '../components/SiteDetails' import React, { Component } from 'react'; import { fetchSiteData } from '../actions/thunks' import { selectSite } from '../actions/actionObjects' -import { getSelectedSiteId, getSelectedSiteName, getSelectedSiteFlags, getSelectedSiteIsLoading, getSelectedSiteHasError, getSelectedSiteIsLoaded } from '../selectors' +import PropTypes from 'prop-types' + +import { getSelectedSiteId, + getSelectedSiteName, + getSelectedSiteFlags, + getSelectedSiteIsLoading, + getSelectedSiteHasError, + getSelectedSiteIsLoaded } from '../selectors' class SiteDetailsContainer extends Component { @@ -54,6 +61,15 @@ class SiteDetailsContainer extends Component { } } +SiteDetailsContainer.propTypes = { + hasErrorLoading: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + siteId: PropTypes.string.isRequired, + siteName: PropTypes.string.isRequired, + isLoaded: PropTypes.bool.isRequired, + siteFlags: PropTypes.array.isRequired +} + export default connect(state => ( { siteId: getSelectedSiteId(state), diff --git a/app/js/containers/SiteListContainer.jsx b/app/js/containers/SiteListContainer.jsx index bfe9d81..7b06bdb 100644 --- a/app/js/containers/SiteListContainer.jsx +++ b/app/js/containers/SiteListContainer.jsx @@ -2,7 +2,9 @@ import { connect } from 'react-redux' import SiteList from '../components/SiteList' import React, { Component } from 'react'; import { fetchSitesList } from '../actions/thunks' +import { unselectActiveSite } from '../actions/actionObjects' import { getSitesListIsLoading, getSitesListIsLoadingError, getSitesArr } from '../selectors' +import PropTypes from 'prop-types' class SiteListContainer extends Component { @@ -11,6 +13,8 @@ class SiteListContainer extends Component { const { dispatch, sites, sitesListIsLoading } = this.props + dispatch(unselectActiveSite()); + if(sitesListIsLoading){ console.log("The Site List is already loading, no need to load it again."); return; @@ -32,6 +36,13 @@ class SiteListContainer extends Component { } } +// The shape of the array will be validated within SiteList, the props are simply forwarded. +SiteListContainer.propTypes = { + sites: PropTypes.array.isRequired, + sitesListIsLoading: PropTypes.bool.isRequired, + sitesListLoadingError: PropTypes.bool.isRequired +} + export default connect(state => ( { sites: getSitesArr(state), diff --git a/app/js/lib/constants.js b/app/js/lib/constants.js index 2cdfb4a..4274125 100644 --- a/app/js/lib/constants.js +++ b/app/js/lib/constants.js @@ -1,4 +1,15 @@ // Ideally this would come from ENV variables. const restBasePath = "http://localhost:3000" -export const URL_SITES_LIST = restBasePath + "/sites" \ No newline at end of file +export const URL_SITES_LIST = restBasePath + "/sites" + +export const possibleFlagValues = [ + "Advertiser - Location Priority", + "Retailer - Location Priority", + "Retailer - Showcase", + "GSTV - Site Visit", + "GSTV - Showcase", + "GSTV - Nielsen Survey", + "GSTV - Research Survey", + "GSTV - Unsellable" + ] \ No newline at end of file diff --git a/app/js/reducers/index.js b/app/js/reducers/index.js index ec1d1f4..033d27a 100644 --- a/app/js/reducers/index.js +++ b/app/js/reducers/index.js @@ -2,54 +2,23 @@ import { combineReducers } from 'redux' import * as actionTypes from '../actions/actionTypes' export default combineReducers({ - sitesListDetails, - sitesArr + sitesArr, + sitesListDetails }) -// This reducer is just concerned about details of the list of Sites. -// For example, if the list is loading from the server, or which one of the ID's is currently selected. -function sitesListDetails ( - state = { - sitesListIsLoading:false, - sitesListLoadingError:false, - selectedSiteId:0 - }, - action) { - - switch (action.type) { - - case actionTypes.SITES_LIST_LOADING: - - if(typeof action.payload !== "boolean") - throw new Error("Error in reducer for SITES_LIST_LOADING. action.payload should be Boolean"); - - return Object.assign({}, state, {sitesListIsLoading: action.payload }) - - case actionTypes.SELECT_SITE: - - if(action.payload.show) - return Object.assign({}, state, {selectedSiteId: action.payload.siteId }) - else - return Object.assign({}, state, {selectedSiteId: 0 }) - - case actionTypes.SITES_LIST_RESPONSE: - - if(action.error) - return Object.assign({}, state, {sitesListLoadingError: true, sitesListIsLoading: false }) - else - return Object.assign({}, state, {sitesListLoadingError: false, sitesListIsLoading: false }) - - default: - return state - } -} - // This reducer handles communication for individual Sites and will take care of merging any Site-specific data into the array. function sitesArr (state = [], action) { switch (action.type) { + case actionTypes.UNSELECT_ACTIVE_SITE: { + + // Ran into a little problem here because it's not possible to quickly get the ID of the "selected Site" because of the ways that the reducers were separated. + // It wouldn't be efficient in production, but one solution is to loop through all of the array elements an reset any Open/Edit flags. + return state; + } + case actionTypes.SITES_LIST_RESPONSE: { if(action.error){ @@ -64,6 +33,22 @@ function sitesArr (state = [], action) { } } + case actionTypes.ADD_FLAG: { + + const siteArrCopy = state.concat(); + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.show !== "boolean") + throw new Error("Error in reducer for ADD_FLAG. action.payload.show should be Boolean"); + + siteObj.showAddFlagForm = action.payload.show; + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj; + + return siteArrCopy; + } + case actionTypes.SITE_DATA_LOADING: { const siteArrCopy = state.concat(); @@ -75,9 +60,6 @@ function sitesArr (state = [], action) { siteObj.isLoading = action.payload.isLoading; - if(!siteObj.isLoading) - siteObj.isLoaded = false; - siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj; return siteArrCopy; @@ -122,6 +104,49 @@ function sitesArr (state = [], action) { } } +// This reducer is just concerned about details of the list of Sites. +// For example, if the list is loading from the server, or which one of the ID's is currently selected. +function sitesListDetails ( + state = { + sitesListIsLoading:false, + sitesListLoadingError:false, + selectedSiteId:"" + }, + action) { + + switch (action.type) { + + case actionTypes.SITES_LIST_LOADING: + + if(typeof action.payload !== "boolean") + throw new Error("Error in reducer for SITES_LIST_LOADING. action.payload should be Boolean"); + + return Object.assign({}, state, {sitesListIsLoading: action.payload }) + + case actionTypes.SELECT_SITE: + + if(action.payload.show) + return Object.assign({}, state, {selectedSiteId: action.payload.siteId }) + else + return Object.assign({}, state, {selectedSiteId: "" }) + + case actionTypes.UNSELECT_ACTIVE_SITE: + + return Object.assign({}, state, {selectedSiteId: "" }) + + case actionTypes.SITES_LIST_RESPONSE: + + if(action.error) + return Object.assign({}, state, {sitesListLoadingError: true, sitesListIsLoading: false }) + else + return Object.assign({}, state, {sitesListLoadingError: false, sitesListIsLoading: false }) + + default: + return state + } +} + + const getSiteIndexWithinStateArrayById = (siteId, sitesArr) => { diff --git a/app/js/selectors/index.js b/app/js/selectors/index.js index 2a61317..9b0404f 100644 --- a/app/js/selectors/index.js +++ b/app/js/selectors/index.js @@ -99,4 +99,15 @@ export const getSelectedSiteName = createSelector( return selectedSiteIdObj.name } -) \ No newline at end of file +) + +export const getShowAddFlagFormForSelectedSite = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.showAddFlagForm ? true : false + } +) From 2e02815c701588d8a2ac71674f11500d41d0d093 Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Tue, 29 Aug 2017 16:15:23 -0700 Subject: [PATCH 10/14] Now have the ability to add a new Flag to a site with validation capabilities. --- Brian Task List.txt | 17 +++- app/js/actions/thunks.js | 6 +- app/js/components/AddOrEditFlag.jsx | 63 ++++++++++++-- app/js/components/SiteDetails.jsx | 10 ++- app/js/containers/AddFlagContainer.jsx | 18 +++- app/js/containers/SiteDetailsContainer.jsx | 16 +++- app/js/reducers/index.js | 97 +++++++++++++++------- app/js/selectors/index.js | 24 +++++- package.json | 1 + server/rest.js | 20 +++-- 10 files changed, 209 insertions(+), 63 deletions(-) diff --git a/Brian Task List.txt b/Brian Task List.txt index a389c35..fce7fe8 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,9 +1,20 @@ +[] Sort the results from the SiteList rest call. Mongo is putting the most recently saved document to the bottom. +[] When selecting a Site, make sure to clear out any "hasErrorSaving" flag. Otherwise an error message could be hanging around from the last attempt +[x] Solve MongoDB error when trying to update the Site Object: 'Modifiers operate on fields but we found type string instead. For example: {$mod: {: ...}} not {$set: "{"flags":[{"flagType":"Retailer - Location Priority","startDate":"8/25/18","endDate":""}]}"}', +[x] Wire up the REST call for creating a new Flag from within the AddFlagContainer component. + [] Solve error on REST service: Method PUT is not allowed by Access-Control-Allow-Methods in preflight response. + [x] Just change the PUT route to a POST for now. + [x] Find out why JSON body is not showing up on the server through req.body. + [x] Install the Body Parser middle-ware for Express. + [x] Find out why FETCH is doing a pre-flight request (i.e. OPTIONS whenever ApplicationType JSON is put in the headers) + [x] Figure out a way to use BodyParser with "text/plain" or write a custom middleware to chunk the req.body. +[x] Add form validation to the AddOrEditFlag dumb component. [] Clean up all of the state properties on the individual Sites array when the SiteDetailList is closed within the Reducer (likely by brute force). For example, if the Add Flag form is open on a view, then it appears open when re-visiting the same site details page. [x] Fix weird warning that happens when canceling the AddFlag Form: Form submission canceled because the form is not connected [] Find out why HotReloading doesn't work within a Route (on the Site Details page). [x] Solve error from AddFlagContainerComponent: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) [x] Solve infinite loop that started happening after clicking into a SiteDetails with an AddEditRow component. -[] Abstract the Form for Add/Edit flag so that it can be re-used for both types (like a New/Edit registration page). +[x] Abstract the Form for Add/Edit flag so that it can be re-used for both types (like a New/Edit registration page). [x] Ensure that all Components have PropTypes configured. [] Design process for processing the Flag objects in an array on the Site Details page. [] Create objects which accept a SiteID and a FlagIndex as a prop. That way there's no need to pass forward/back callbacks and pollute the SiteDetailsContainer. @@ -34,7 +45,7 @@ [x] How to deal with Errors on PUT "SiteData" which has a SiteID associated with the Payload. [x] Create Thunk Aysnc Actions for GetSiteList, GetSiteData(SiteID), SaveSiteData(siteId) [x] Decide on a process for loading data from the server and saving mutations. -[] Design shape of reducers +[x] Design shape of reducers [] Find a way to keep the "key" entries unique on components within Site Details view. [] Figure out how to use Nomalizr with "flag" entries. They don't have unique ID's on their own. [] Create REST API @@ -87,7 +98,7 @@ [] Verify that Pollyfill worked, even if WebPack compiled OK. [x] Install "prop-types" -[] Research CSS Solution. CSS Modules ... Styled Components??? +[x] Research CSS Solution. CSS Modules ... Styled Components??? [x] Get JSX / Babel Package for SublimeText [] Figure out why Sublime Text is not honoring the .editorconfig IDE settings. [] Figure out why .sublime_project is not allowing Tabbed spaces. diff --git a/app/js/actions/thunks.js b/app/js/actions/thunks.js index 498b5c4..9f0e1b4 100644 --- a/app/js/actions/thunks.js +++ b/app/js/actions/thunks.js @@ -118,10 +118,10 @@ export function saveSiteData(siteId, siteObj) { dispatch(siteDataIsSaving(siteId, true)); fetch(URL_SITES_LIST + "/" + siteId, { - method: 'PUT', - data: { + method: 'POST', + body: JSON.stringify({ flags: siteObj.flags - } + }) }) .then((response) => { diff --git a/app/js/components/AddOrEditFlag.jsx b/app/js/components/AddOrEditFlag.jsx index 535abb2..3619c46 100644 --- a/app/js/components/AddOrEditFlag.jsx +++ b/app/js/components/AddOrEditFlag.jsx @@ -27,27 +27,72 @@ export default class AddOrEditFlag extends Component { // The AddOrEditFlag should call this method with the Flag Object (to be inserted within the flags array) after it passes validation. handleFormSubmit(event) { - this.setState({errorMessage: ""}); + this.setState({errorMessage: ""}) - alert("submit form." + JSON.stringify(this.state)); + let newErrorMessage = "" + let newStartTimestampUnix = 0; + let newEndTimestampUnix = 0; - this.setState({errorMessage: "New Error"}); + if(!this.state.flagType) + newErrorMessage = "You must select a Flag Type before submitting the form." + + if(this.state.startDate){ + + var possibleStartTimestamp = Date.parse(this.state.startDate) + + if(Number.isNaN(possibleStartTimestamp)) + newErrorMessage = "The start date is invalid." + else + newStartTimestampUnix = possibleStartTimestamp + } + if(this.state.endDate){ + + var possibleEndTimestamp = Date.parse(this.state.endDate) + + if(Number.isNaN(possibleEndTimestamp)) + newErrorMessage = "The end date is invalid." + else + newEndTimestampUnix = possibleEndTimestamp + } + + if(newStartTimestampUnix && newEndTimestampUnix){ + + if(new Date(newStartTimestampUnix).toLocaleDateString() === new Date(newEndTimestampUnix).toLocaleDateString()) + newErrorMessage = "The start date may not be the same date as the end date." + + if(newStartTimestampUnix > newEndTimestampUnix) + newErrorMessage = "The start date must be before the end date." + } + + if(!newStartTimestampUnix && !newEndTimestampUnix) + newErrorMessage = "Either the start date or the end date must be provided." + + if(newErrorMessage) + this.setState({errorMessage: newErrorMessage}) + else + this.props.onSaveFlag({ + flagType: this.state.flagType, + startDate: this.state.startDate, + endDate: this.state.endDate + }) + + event.preventDefault() } handleCancel(event) { - this.props.onClose(); + this.props.onClose() } handleFlagTypeChange(event) { - this.setState({flagType: event.target.value}); + this.setState({flagType: event.target.value}) } handleStartDateChange(event) { - this.setState({startDate: event.target.value}); + this.setState({startDate: event.target.value}) } handleEndDateChange(event) { - this.setState({endDate: event.target.value}); + this.setState({endDate: event.target.value}) } componentDidMount() { @@ -57,7 +102,7 @@ export default class AddOrEditFlag extends Component { render() { let listMenuOptions = possibleFlagValues.map((flagType, index) => - + ) if(this.props.isAddForm) @@ -68,7 +113,7 @@ export default class AddOrEditFlag extends Component { let errorMessageBlock = ""; if(this.state.errorMessage) - errorMessageBlock =
{this.state.errorMessage}
+ errorMessageBlock =
{this.state.errorMessage}
return ( diff --git a/app/js/components/SiteDetails.jsx b/app/js/components/SiteDetails.jsx index 2065950..4c6231b 100644 --- a/app/js/components/SiteDetails.jsx +++ b/app/js/components/SiteDetails.jsx @@ -25,6 +25,8 @@ function SiteDetails (props) { var headerTag =

Site ID #{props.siteId}, Site Name: {props.siteName}

var closeLink =
Close
var addFlagButton =
+ var isSavingTag =
... saving ...
+ var hasSavingErrorTag =
There was an error saving.
var flagsList = "Flags" // var flagsList = this.props.siteFlags.map((flagObj, flagIndex) => @@ -38,6 +40,8 @@ function SiteDetails (props) { {headerTag} {closeLink} {addFlagButton} + { props.isSaving && isSavingTag } + { props.hasErrorSaving && hasSavingErrorTag }
There are no site flags
) @@ -49,6 +53,8 @@ function SiteDetails (props) { {headerTag} {closeLink} {addFlagButton} + { props.isSaving && isSavingTag } + { props.hasErrorSaving && hasSavingErrorTag }
Total Flags {props.siteFlags.length}
{flagsList}
@@ -62,7 +68,9 @@ SiteDetails.propTypes = { siteId: PropTypes.string.isRequired, siteName: PropTypes.string.isRequired, isLoaded: PropTypes.bool.isRequired, - siteFlags: PropTypes.array.isRequired + siteFlags: PropTypes.array.isRequired, + hasErrorSaving: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired } export default SiteDetails \ No newline at end of file diff --git a/app/js/containers/AddFlagContainer.jsx b/app/js/containers/AddFlagContainer.jsx index ad9ea3f..c6737f3 100644 --- a/app/js/containers/AddFlagContainer.jsx +++ b/app/js/containers/AddFlagContainer.jsx @@ -3,7 +3,7 @@ import SiteList from '../components/SiteList' import React, { Component } from 'react'; import { saveSiteData } from '../actions/thunks' import { addFlag, addFlagCancel } from '../actions/actionObjects' -import { getShowAddFlagFormForSelectedSite, getSelectedSiteId } from '../selectors' +import { getShowAddFlagFormForSelectedSite, getSelectedSiteId, getSelectedSiteObj } from '../selectors' import AddOrEditFlag from '../components/AddOrEditFlag' import PropTypes from 'prop-types' @@ -19,9 +19,18 @@ class AddFlagContainer extends Component { this.handleShowAddForm = this.handleShowAddForm.bind(this); } - // The AddOrEditFlag should call this method with the Flag Object (to be inserted within the flags array) after it passes validation. + // The AddOrEditFlag dummy component should call this method with the Flag Object (to be inserted within the SiteObject's flags array) after it passes validation. handleSaveFlag(flagObj) { + var existingFlagsArr = this.props.siteObj.flags.concat(); + + existingFlagsArr.push(flagObj); + + var updatedSiteObj = Object.assign({}, this.props.siteObj, { flags: existingFlagsArr }) + + // Get the existing Site Object and add just push the new Flag object onto its array. + // The REST call must update the entire SiteObj and everything in it. + this.props.dispatch(saveSiteData(this.props.siteId, updatedSiteObj)) } handleCloseAddForm() { @@ -37,10 +46,10 @@ class AddFlagContainer extends Component { } render() { - + if(this.props.showAddFlagForm){ - // Re-use the same component for Editing and Adding a Flag. + // Re-use the same component for Editing too. return ( ( { siteId: getSelectedSiteId(state), + siteObj: getSelectedSiteObj(state), showAddFlagForm: getShowAddFlagFormForSelectedSite(state) } ))(AddFlagContainer) \ No newline at end of file diff --git a/app/js/containers/SiteDetailsContainer.jsx b/app/js/containers/SiteDetailsContainer.jsx index c700005..fe6f61f 100644 --- a/app/js/containers/SiteDetailsContainer.jsx +++ b/app/js/containers/SiteDetailsContainer.jsx @@ -9,7 +9,9 @@ import { getSelectedSiteId, getSelectedSiteName, getSelectedSiteFlags, getSelectedSiteIsLoading, - getSelectedSiteHasError, + getSelectedSiteIsSaving, + getSelectedSiteHasErrorLoading, + getSelectedSiteHasErrorSaving, getSelectedSiteIsLoaded } from '../selectors' @@ -54,7 +56,9 @@ class SiteDetailsContainer extends Component { "isLoading":this.props.isLoading, "siteFlags":this.props.siteFlags, "isLoaded":this.props.isLoaded, - "hasErrorLoading":this.props.hasErrorLoading + "hasErrorLoading":this.props.hasErrorLoading, + "hasErrorSaving":this.props.hasErrorSaving, + "isSaving":this.props.isSaving } return @@ -67,7 +71,9 @@ SiteDetailsContainer.propTypes = { siteId: PropTypes.string.isRequired, siteName: PropTypes.string.isRequired, isLoaded: PropTypes.bool.isRequired, - siteFlags: PropTypes.array.isRequired + siteFlags: PropTypes.array.isRequired, + hasErrorSaving: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired } export default connect(state => ( @@ -76,6 +82,8 @@ export default connect(state => ( siteName: getSelectedSiteName(state), siteFlags: getSelectedSiteFlags(state), isLoading: getSelectedSiteIsLoading(state), + isSaving: getSelectedSiteIsSaving(state), isLoaded: getSelectedSiteIsLoaded(state), - hasErrorLoading: getSelectedSiteHasError(state) + hasErrorLoading: getSelectedSiteHasErrorLoading(state), + hasErrorSaving: getSelectedSiteHasErrorSaving(state) }))(SiteDetailsContainer) \ No newline at end of file diff --git a/app/js/reducers/index.js b/app/js/reducers/index.js index 033d27a..be44294 100644 --- a/app/js/reducers/index.js +++ b/app/js/reducers/index.js @@ -1,5 +1,6 @@ import { combineReducers } from 'redux' import * as actionTypes from '../actions/actionTypes' +import cloneDeep from 'lodash.clonedeep' export default combineReducers({ sitesArr, @@ -12,10 +13,46 @@ function sitesArr (state = [], action) { switch (action.type) { + case actionTypes.SITE_DATA_SAVE_SENDING: { + + const siteArrCopy = state.concat(); + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.isSaving !== "boolean") + throw new Error("Error in reducer for SITE_DATA_SAVE_SENDING. action.payload.isSaving should be Boolean") + + siteObj.isSaving = action.payload.isSaving + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj + + return siteArrCopy + } + + case actionTypes.SITE_DATA_SAVE_RESPONSE: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const indexOfSiteInArr = getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy) + + siteArrCopy[indexOfSiteInArr] = getSiteObjById(siteIdFromPayload, siteArrCopy) + + siteArrCopy[indexOfSiteInArr].isSaving = false + + // If there's no error then close the form(s), otherwise leave them open so that the user can try again. + if(action.error) + siteArrCopy[indexOfSiteInArr].hasErrorSaving = true + else + siteArrCopy[indexOfSiteInArr].showAddFlagForm = false + + return siteArrCopy + } + case actionTypes.UNSELECT_ACTIVE_SITE: { // Ran into a little problem here because it's not possible to quickly get the ID of the "selected Site" because of the ways that the reducers were separated. - // It wouldn't be efficient in production, but one solution is to loop through all of the array elements an reset any Open/Edit flags. + // It wouldn't be efficient in production, but one solution is to loop through all of the array elements an reset any Open/Edit flags. + // The solution is either to normalize the data or add a Root Reducer. return state; } @@ -35,69 +72,69 @@ function sitesArr (state = [], action) { case actionTypes.ADD_FLAG: { - const siteArrCopy = state.concat(); + const siteArrCopy = state.concat() const siteIdFromPayload = action.payload.siteId const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) if(typeof action.payload.show !== "boolean") - throw new Error("Error in reducer for ADD_FLAG. action.payload.show should be Boolean"); + throw new Error("Error in reducer for ADD_FLAG. action.payload.show should be Boolean") - siteObj.showAddFlagForm = action.payload.show; + siteObj.showAddFlagForm = action.payload.show - siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj; + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj - return siteArrCopy; + return siteArrCopy } case actionTypes.SITE_DATA_LOADING: { - const siteArrCopy = state.concat(); + const siteArrCopy = state.concat() const siteIdFromPayload = action.payload.siteId const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) if(typeof action.payload.isLoading !== "boolean") - throw new Error("Error in reducer for SITE_DATA_LOADING. action.payload.isLoading should be Boolean"); + throw new Error("Error in reducer for SITE_DATA_LOADING. action.payload.isLoading should be Boolean") - siteObj.isLoading = action.payload.isLoading; + siteObj.isLoading = action.payload.isLoading - siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj; + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj - return siteArrCopy; + return siteArrCopy } case actionTypes.SITE_DATA_RESPONSE: { - const siteArrCopy = state.concat(); + const siteArrCopy = state.concat() const siteIdFromPayload = action.payload.siteId - const indexOfSiteInArr = getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy); + const indexOfSiteInArr = getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy) // If there's an error just use the existing record from the current state and set some flags on it. if(action.error){ - const existingSiteObjCopy = getSiteObjById(siteIdFromPayload, siteArrCopy); + const existingSiteObjCopy = getSiteObjById(siteIdFromPayload, siteArrCopy) - existingSiteObjCopy.isLoading = false; - existingSiteObjCopy.isLoaded = false; - existingSiteObjCopy.hasErrorLoading = true; + existingSiteObjCopy.isLoading = false + existingSiteObjCopy.isLoaded = false + existingSiteObjCopy.hasErrorLoading = true - siteArrCopy[indexOfSiteInArr] = existingSiteObjCopy; + siteArrCopy[indexOfSiteInArr] = existingSiteObjCopy - return siteArrCopy; + return siteArrCopy } // Otherwise replace the existing Site object on the state array with the object returned from the REST call. const siteObjFromPayload = action.payload.siteObj if(siteIdFromPayload !== siteObjFromPayload._id) - throw new Error("Error in SITE_DATA_RESPONSE reducer. The Payload Site ID does not match the _id key within the REST response."); + throw new Error("Error in SITE_DATA_RESPONSE reducer. The Payload Site ID does not match the _id key within the REST response.") - siteObjFromPayload.isLoading = false; - siteObjFromPayload.isLoaded = true; - siteObjFromPayload.hasErrorLoading = false; + siteObjFromPayload.isLoading = false + siteObjFromPayload.isLoaded = true + siteObjFromPayload.hasErrorLoading = false - siteArrCopy[indexOfSiteInArr] = siteObjFromPayload; + siteArrCopy[indexOfSiteInArr] = siteObjFromPayload - return siteArrCopy; + return siteArrCopy } default: return state @@ -119,7 +156,7 @@ function sitesListDetails ( case actionTypes.SITES_LIST_LOADING: if(typeof action.payload !== "boolean") - throw new Error("Error in reducer for SITES_LIST_LOADING. action.payload should be Boolean"); + throw new Error("Error in reducer for SITES_LIST_LOADING. action.payload should be Boolean") return Object.assign({}, state, {sitesListIsLoading: action.payload }) @@ -153,15 +190,15 @@ const getSiteIndexWithinStateArrayById = (siteId, sitesArr) => { const foundIndex = sitesArr.findIndex(siteObj => siteObj._id === siteId) if(foundIndex === -1) - throw new Error("Error in Reducer function getSiteIndexWithinStateArrayById. The given Site ID couldn't be found within the Site Array:" + siteId); + throw new Error("Error in Reducer function getSiteIndexWithinStateArrayById. The given Site ID couldn't be found within the Site Array:" + siteId) - return foundIndex; + return foundIndex } const getSiteObjById = (siteId, sitesArr) => { - const indexOfSite = getSiteIndexWithinStateArrayById(siteId, sitesArr); + const indexOfSite = getSiteIndexWithinStateArrayById(siteId, sitesArr) - return Object.assign({}, sitesArr[indexOfSite]); + return cloneDeep(sitesArr[indexOfSite]) } diff --git a/app/js/selectors/index.js b/app/js/selectors/index.js index 9b0404f..e8615e9 100644 --- a/app/js/selectors/index.js +++ b/app/js/selectors/index.js @@ -68,7 +68,7 @@ export const getSelectedSiteIsLoaded = createSelector( } ) -export const getSelectedSiteHasError = createSelector( +export const getSelectedSiteHasErrorLoading = createSelector( getSelectedSiteObj, selectedSiteIdObj => { @@ -79,6 +79,28 @@ export const getSelectedSiteHasError = createSelector( } ) +export const getSelectedSiteHasErrorSaving = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.hasErrorSaving ? true : false + } +) + +export const getSelectedSiteIsSaving = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.isSaving ? true : false + } +) + export const getSelectedSiteFlags = createSelector( getSelectedSiteObj, selectedSiteIdObj => { diff --git a/package.json b/package.json index 2dcd6d7..5de8103 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "express": "^4.15.4", "fetch-everywhere": "^1.0.5", "html-webpack-plugin": "^2.30.1", + "lodash.clonedeep": "^4.5.0", "mongodb": "^2.2.31", "prop-types": "^15.5.10", "react": "^15.6.1", diff --git a/server/rest.js b/server/rest.js index 3b20b29..9660037 100644 --- a/server/rest.js +++ b/server/rest.js @@ -5,12 +5,16 @@ const fs = require('fs') const app = express() const MongoClient = require('mongodb').MongoClient const MongoObjectId = require('mongodb').ObjectID +const bodyParser = require('body-parser') +app.use(bodyParser.text()); + app.all('/*', function(req, res, next) { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Headers", "X-Requested-With"); - next(); + res.header("Access-Control-Allow-Origin", "*") + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,OPTIONS,HEAD') + res.header("Access-Control-Allow-Headers", "X-Requested-With") + next(); }); let db @@ -75,7 +79,7 @@ app.get('/sites/:id', function (req, res) { }) // Updates the Site ID with the given Site document. -app.put('/sites/:id', function (req, res) { +app.post('/sites/:id', function (req, res) { let siteId = MongoObjectId(req.params.id); let siteDocument @@ -84,7 +88,7 @@ app.put('/sites/:id', function (req, res) { siteDocument = JSON.parse(req.body) } catch(e){ - console.log(`Unable parse the JSON Site document for updating: ${siteId}`, err) + console.log(`Unable parse the JSON Site document for updating: ${siteId}`, e) res.send({error: "Unable to parse JSON body."}); return; } @@ -96,7 +100,7 @@ app.put('/sites/:id', function (req, res) { // This API shouldn't let callers modify the Site name or _id. let flagsPropertyOnly = Object.assign({}, {"flags":siteDocument.flags}) - db.collection('sites').updateOne({_id: siteId}, {$set:JSON.stringify(flagsPropertyOnly)}, {safe:true}, (err, result) => { + db.collection('sites').updateOne({_id: siteId}, {$set:flagsPropertyOnly}, {safe:true}, (err, result) => { if (err) { console.log(`Unable update the Site document for ID: ${siteId}`, err) @@ -104,14 +108,14 @@ app.put('/sites/:id', function (req, res) { return; } - if(result !== 1){ + if(!result.result || result.result.n !== 1){ console.log(`Unable update the Site document for ID: ${siteId}. Mongo returned with an invalid result: ${result}`) res.status(404); res.send({error: "The update operation failed. The ID is likely invalid."}); return; } - res.send(result); + res.send("OK"); }) }) From b91ebe4d691efd2030ec8bb7f72659068aec2a6d Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Tue, 29 Aug 2017 21:23:39 -0700 Subject: [PATCH 11/14] Now the New/Edit functionality is stable. Still need to do the Delete functionality and integreate Date pickers. --- Brian Task List.txt | 8 +- app/js/actions/actionObjects.js | 13 --- app/js/actions/actionTypes.js | 2 - app/js/actions/thunks.js | 6 +- app/js/components/AddOrEditFlag.jsx | 50 +++++++----- app/js/components/FlagRow.jsx | 27 +++++++ app/js/components/SiteDetails.jsx | 56 ++++++++----- app/js/containers/FlagRowContainer.jsx | 107 +++++++++++++++++++++++++ app/js/reducers/index.js | 53 ++++++++++-- app/js/selectors/index.js | 15 ++++ 10 files changed, 269 insertions(+), 68 deletions(-) create mode 100644 app/js/components/FlagRow.jsx create mode 100644 app/js/containers/FlagRowContainer.jsx diff --git a/Brian Task List.txt b/Brian Task List.txt index fce7fe8..7e8d284 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,3 +1,9 @@ +[x] Find out why the JSX List is not matching to the Filtered array (by date).... weird, weird, weird. +[x] Reset the Edit/New form on the state after a successful update. +[x] The Edit functionality is not selecting the default option in the list. +[x] Need to solve a bug dealing with "skipped records". Currently the SiteDetails will print a message if there are no Flags in the database for the given site. However it's possible that the component will return NULL if the EndDate exists in the past. +[x] Create a dummy FlagRow component that will wire back the click events for EDIT/DELETE +[x] Create a FlagRowContainer that will take care of rendering the Dummy Flag row, and also any EDIT functionality. [] Sort the results from the SiteList rest call. Mongo is putting the most recently saved document to the bottom. [] When selecting a Site, make sure to clear out any "hasErrorSaving" flag. Otherwise an error message could be hanging around from the last attempt [x] Solve MongoDB error when trying to update the Site Object: 'Modifiers operate on fields but we found type string instead. For example: {$mod: {: ...}} not {$set: "{"flags":[{"flagType":"Retailer - Location Priority","startDate":"8/25/18","endDate":""}]}"}', @@ -10,7 +16,7 @@ [x] Figure out a way to use BodyParser with "text/plain" or write a custom middleware to chunk the req.body. [x] Add form validation to the AddOrEditFlag dumb component. [] Clean up all of the state properties on the individual Sites array when the SiteDetailList is closed within the Reducer (likely by brute force). For example, if the Add Flag form is open on a view, then it appears open when re-visiting the same site details page. -[x] Fix weird warning that happens when canceling the AddFlag Form: Form submission canceled because the form is not connected +[x] Fix weird warning that happens when canceling the AddFlag Form: "Form submission canceled because the form is not connected" [] Find out why HotReloading doesn't work within a Route (on the Site Details page). [x] Solve error from AddFlagContainerComponent: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) [x] Solve infinite loop that started happening after clicking into a SiteDetails with an AddEditRow component. diff --git a/app/js/actions/actionObjects.js b/app/js/actions/actionObjects.js index 66dee1f..11379fa 100644 --- a/app/js/actions/actionObjects.js +++ b/app/js/actions/actionObjects.js @@ -43,13 +43,6 @@ export function editFlagCancel(siteId, flagIndex) { } } -export function updateFlag(siteId, flagIndex, flagObj) { - return { - type: actionTypes.UPDATE_FLAG, - payload: { siteId, flagIndex, flagObj } - } -} - export function addFlag(siteId) { return { type: actionTypes.ADD_FLAG, @@ -64,10 +57,4 @@ export function addFlagCancel(siteId) { } } -export function createNewFlag(siteId, flagObj) { - return { - type: actionTypes.CREATE_FLAG, - payload: { siteId, flagObj } - } -} diff --git a/app/js/actions/actionTypes.js b/app/js/actions/actionTypes.js index 458663e..5875366 100644 --- a/app/js/actions/actionTypes.js +++ b/app/js/actions/actionTypes.js @@ -1,8 +1,6 @@ export const EDIT_FLAG = 'EDIT_FLAG' -export const UPDATE_FLAG = 'UPDATE_FLAG' export const ADD_FLAG = 'ADD_FLAG' -export const CREATE_FLAG = 'CREATE_FLAG' export const CONFIRM_DELETE_FLAG = 'CONFIRM_DELETE_FLAG' diff --git a/app/js/actions/thunks.js b/app/js/actions/thunks.js index 9f0e1b4..561f632 100644 --- a/app/js/actions/thunks.js +++ b/app/js/actions/thunks.js @@ -130,7 +130,7 @@ export function saveSiteData(siteId, siteObj) { return response }) - .then(response => dispatch(siteDataSaveSuccess(siteId))) + .then(response => dispatch(siteDataSaveSuccess(siteId, siteObj))) .catch(error => dispatch(siteDataSaveError(siteId, error))) }; } @@ -143,11 +143,11 @@ function siteDataIsSaving(siteId, isSaving) { } } -function siteDataSaveSuccess(siteId) { +function siteDataSaveSuccess(siteId, siteObj) { return { type: actionTypes.SITE_DATA_SAVE_RESPONSE, - payload: { siteId }, + payload: { siteId, siteObj }, error: false } } diff --git a/app/js/components/AddOrEditFlag.jsx b/app/js/components/AddOrEditFlag.jsx index 3619c46..4768abe 100644 --- a/app/js/components/AddOrEditFlag.jsx +++ b/app/js/components/AddOrEditFlag.jsx @@ -45,6 +45,7 @@ export default class AddOrEditFlag extends Component { else newStartTimestampUnix = possibleStartTimestamp } + if(this.state.endDate){ var possibleEndTimestamp = Date.parse(this.state.endDate) @@ -117,29 +118,34 @@ export default class AddOrEditFlag extends Component { return ( -
- {errorMessageBlock} - - - - - - - - - + +
+ {this.props.isAddForm ? "Add a New Site Flag" : "Edit Site Flag"} + + {errorMessageBlock} + + + + + + +
+ + +
+
); } diff --git a/app/js/components/FlagRow.jsx b/app/js/components/FlagRow.jsx new file mode 100644 index 0000000..a91fb11 --- /dev/null +++ b/app/js/components/FlagRow.jsx @@ -0,0 +1,27 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import PropTypes from 'prop-types' + + +function FlagRow (props) { + + return (
+
Flag Type: {props.flagType}
+
{props.startDate && "Start Date: "}{props.startDate}
+
{props.endDate && "End Date: "}{props.endDate}
+
+ + +
+
) +} + +FlagRow.propTypes = { + onShowEdit: PropTypes.func.isRequired, + onConfirmDelete: PropTypes.func.isRequired, + flagType: PropTypes.string.isRequired, + startDate: PropTypes.string.isRequired, + endDate: PropTypes.string.isRequired +} + +export default FlagRow; diff --git a/app/js/components/SiteDetails.jsx b/app/js/components/SiteDetails.jsx index 4c6231b..c526e35 100644 --- a/app/js/components/SiteDetails.jsx +++ b/app/js/components/SiteDetails.jsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { Link } from 'react-router-dom' import PropTypes from 'prop-types' import AddFlagContainer from '../containers/AddFlagContainer' +import FlagRowContainer from '../containers/FlagRowContainer' function SiteDetails (props) { @@ -22,27 +23,40 @@ function SiteDetails (props) {
The Site Details haven't been loaded from the server yet.
) - var headerTag =

Site ID #{props.siteId}, Site Name: {props.siteName}

- var closeLink =
Close
- var addFlagButton =
- var isSavingTag =
... saving ...
- var hasSavingErrorTag =
There was an error saving.
- var flagsList = "Flags" + // Assign the original index to an object property before filtering the list. + // The array position is significant, it will be used as an identifier to a Container component below. + let filteredSiteFlagsByEndDate = props.siteFlags.map((flagObj, siteFlagIndex) => + Object.assign({}, flagObj, {siteFlagIndex}) + ) - // var flagsList = this.props.siteFlags.map((flagObj, flagIndex) => - // - // ) + // Filter out any flags that have an EndDate, and which occurs in the past. + filteredSiteFlagsByEndDate = filteredSiteFlagsByEndDate.filter(flagObj => + (flagObj.endDate && Date.parse(flagObj.endDate) < Date.now()) ? false : true + ) + + const flagsTableRows = filteredSiteFlagsByEndDate.map((flagObj, flagIndex) => + + ) + + const isSavingTag =
... saving ...
+ const hasSavingErrorTag =
There was an error saving to the server.
+ + const commonHeader = ( +
+

{props.siteName}

+
Close Site Details
+
+ { props.isSaving && isSavingTag } + { props.hasErrorSaving && hasSavingErrorTag } +
+ ) if(!props.siteFlags.length){ return (
- {headerTag} - {closeLink} - {addFlagButton} - { props.isSaving && isSavingTag } - { props.hasErrorSaving && hasSavingErrorTag } -
There are no site flags
+ {commonHeader} +
There are no site flags.
) } @@ -50,13 +64,11 @@ function SiteDetails (props) { return (
- {headerTag} - {closeLink} - {addFlagButton} - { props.isSaving && isSavingTag } - { props.hasErrorSaving && hasSavingErrorTag } -
Total Flags {props.siteFlags.length}
-
{flagsList}
+ {commonHeader} +
+ Active Flags: {filteredSiteFlagsByEndDate.length} - Expired: {props.siteFlags.length - filteredSiteFlagsByEndDate.length} +
+ {flagsTableRows}
) } diff --git a/app/js/containers/FlagRowContainer.jsx b/app/js/containers/FlagRowContainer.jsx new file mode 100644 index 0000000..ac82c5a --- /dev/null +++ b/app/js/containers/FlagRowContainer.jsx @@ -0,0 +1,107 @@ +import { connect } from 'react-redux' +import SiteList from '../components/SiteList' +import React, { Component } from 'react'; +import { saveSiteData } from '../actions/thunks' +import { editFlag, editFlagCancel, confirmDeleteShow, confirmDeleteHide } from '../actions/actionObjects' +import { getShowAddFlagFormForSelectedSite, getSelectedSiteId, getSelectedSiteObj, getFlagIndexBeingEditedForSelectedSite } from '../selectors' +import AddOrEditFlag from '../components/AddOrEditFlag' +import FlagRow from '../components/FlagRow' +import PropTypes from 'prop-types' + + +class FlagRowContainer extends Component { + + constructor(props) { + + super(props); + + this.handleUpdateFlag = this.handleUpdateFlag.bind(this); + this.handleCancelEdit = this.handleCancelEdit.bind(this); + this.handleShowEdit = this.handleShowEdit.bind(this); + this.handleConfirmDeleteShow = this.handleConfirmDeleteShow.bind(this); + this.handleConfirmDeleteHide = this.handleConfirmDeleteHide.bind(this); + + if(this.props.flagIndex >= this.props.siteObj.flags.length) + throw new Error("Error in FlagRowContainer constructor. The Flag Index is out of bounds."); + } + + handleUpdateFlag(flagObj) { + + var existingFlagsArr = this.props.siteObj.flags.concat(); + + existingFlagsArr[this.props.flagIndex] = flagObj; + + var updatedSiteObj = Object.assign({}, this.props.siteObj, { flags: existingFlagsArr }) + + // Get the existing Site Object and add just push the new Flag object onto its array. + // The REST call must update the entire SiteObj and everything in it. + this.props.dispatch(saveSiteData(this.props.siteId, updatedSiteObj)) + } + + handleCancelEdit() { + this.props.dispatch(editFlagCancel(this.props.siteId, this.props.flagIndex)) + } + + handleShowEdit() { + this.props.dispatch(editFlag(this.props.siteId, this.props.flagIndex)) + } + + handleConfirmDeleteShow() { + this.props.dispatch(confirmDeleteShow(this.props.siteId, this.props.flagIndex)) + } + + handleConfirmDeleteHide() { + this.props.dispatch(confirmDeleteHide(this.props.siteId, this.props.flagIndex)) + } + + componentDidMount() { + + } + + render() { + + const currentFlagObj = this.props.siteObj.flags[this.props.flagIndex] + + const flagRowComponent = ( + + ) + + // If these two match then it means that the given FlagRow is being edited. + if(this.props.flagIndexBeingEdited === this.props.flagIndex){ + + // Re-use the same component for Adding too. + return ( + + ) + } + else{ + return flagRowComponent + } + } +} + +FlagRowContainer.propTypes = { + siteId: PropTypes.string.isRequired, + siteObj: PropTypes.object.isRequired, + flagIndex: PropTypes.number.isRequired, + flagIndexBeingEdited: PropTypes.number.isRequired +} + +export default connect(state => ( + { + siteId: getSelectedSiteId(state), + siteObj: getSelectedSiteObj(state), + flagIndexBeingEdited: getFlagIndexBeingEditedForSelectedSite(state) + } + ))(FlagRowContainer) \ No newline at end of file diff --git a/app/js/reducers/index.js b/app/js/reducers/index.js index be44294..fc78d7d 100644 --- a/app/js/reducers/index.js +++ b/app/js/reducers/index.js @@ -33,17 +33,28 @@ function sitesArr (state = [], action) { const siteArrCopy = state.concat() const siteIdFromPayload = action.payload.siteId + const siteObjectFromPayload = action.payload.siteObj const indexOfSiteInArr = getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy) - siteArrCopy[indexOfSiteInArr] = getSiteObjById(siteIdFromPayload, siteArrCopy) + if(action.error){ - siteArrCopy[indexOfSiteInArr].isSaving = false + // Since there's an error, re-use the same Site object that's currently on the state (not the version sent to the server). + siteArrCopy[indexOfSiteInArr] = getSiteObjById(siteIdFromPayload, siteArrCopy) - // If there's no error then close the form(s), otherwise leave them open so that the user can try again. - if(action.error) + // If there's an error, leave the form(s) open so that the user can try again. siteArrCopy[indexOfSiteInArr].hasErrorSaving = true - else + siteArrCopy[indexOfSiteInArr].isSaving = false + } + else{ + + // Since the update succeeded it is OK to add the new Site Object to the state. + siteArrCopy[indexOfSiteInArr] = cloneDeep(siteObjectFromPayload) + + // Make sure to close the form since the update succeeded. siteArrCopy[indexOfSiteInArr].showAddFlagForm = false + siteArrCopy[indexOfSiteInArr].showEditFormForFlagIndex = undefined + siteArrCopy[indexOfSiteInArr].isSaving = false + } return siteArrCopy } @@ -70,6 +81,35 @@ function sitesArr (state = [], action) { } } + case actionTypes.EDIT_FLAG: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.show !== "boolean") + throw new Error("Error in reducer for EDIT_FLAG. action.payload.show should be Boolean.") + + if(typeof action.payload.flagIndex !== "number") + throw new Error("Error in reducer for EDIT_FLAG. action.payload.flagIndex should be Number.") + + if(!siteObj.flags[action.payload.flagIndex]) + throw new Error("Error in reducer for EDIT_FLAG. The given flagIndex is out of bounds.") + + // If the Site Object stores a number on showEditFormForFlagIndex then it means that the Flag is in a state of being edited. + if(action.payload.show) + siteObj.showEditFormForFlagIndex = action.payload.flagIndex + else + siteObj.showEditFormForFlagIndex = undefined + + // If someone opens/closes an EDIT window then make sure to hide the ADD form. + siteObj.showAddFlagForm = false + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj + + return siteArrCopy + } + case actionTypes.ADD_FLAG: { const siteArrCopy = state.concat() @@ -81,6 +121,9 @@ function sitesArr (state = [], action) { siteObj.showAddFlagForm = action.payload.show + // If someone opens/closes the ADD form on a Site Object, close any EDIT form. + siteObj.showEditFormForFlagIndex = undefined + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj return siteArrCopy diff --git a/app/js/selectors/index.js b/app/js/selectors/index.js index e8615e9..32ea7bd 100644 --- a/app/js/selectors/index.js +++ b/app/js/selectors/index.js @@ -133,3 +133,18 @@ export const getShowAddFlagFormForSelectedSite = createSelector( return selectedSiteIdObj.showAddFlagForm ? true : false } ) + +// Returns -1 if the selected Site Object has no flags being edited, otherwise returns zero-based index corresponding to the position in the array being edited. +export const getFlagIndexBeingEditedForSelectedSite = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj || typeof selectedSiteIdObj.showEditFormForFlagIndex === "undefined") + return -1 + + if(typeof selectedSiteIdObj.showEditFormForFlagIndex !== "number" || !selectedSiteIdObj.flags[selectedSiteIdObj.showEditFormForFlagIndex]) + throw new Error("Error in getFlagIndexBeingEditedForSelectedSite. The Flag Index selected for editing is out of bounds.") + + return selectedSiteIdObj.showEditFormForFlagIndex + } +) From 46d371811ef7d12511e93b4a53e7357a5490981f Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Tue, 29 Aug 2017 23:39:42 -0700 Subject: [PATCH 12/14] Finished implementing the ModalConfirm and the Delete functionality. --- Brian Task List.txt | 7 ++- app/js/components/ModalConfirm.jsx | 45 +++++++++++++++++ app/js/components/SiteDetails.jsx | 2 +- app/js/containers/AddFlagContainer.jsx | 4 -- app/js/containers/FlagRowContainer.jsx | 57 +++++++++++++++++----- app/js/containers/SiteDetailsContainer.jsx | 3 +- app/js/containers/SiteListContainer.jsx | 3 +- app/js/reducers/index.js | 40 +++++++++++++-- app/js/selectors/index.js | 15 ++++++ 9 files changed, 151 insertions(+), 25 deletions(-) create mode 100644 app/js/components/ModalConfirm.jsx diff --git a/Brian Task List.txt b/Brian Task List.txt index 7e8d284..8e4994b 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,3 +1,6 @@ +[x] Finish wiring up the DELETE actions and update the server through a POST. + [x] Make sure that the reducers clear out any array indexes after the server responds because the positions may have changed. +[x] Create a generic a component that can be used for "Delete Flag". [x] Find out why the JSX List is not matching to the Filtered array (by date).... weird, weird, weird. [x] Reset the Edit/New form on the state after a successful update. [x] The Edit functionality is not selecting the default option in the list. @@ -22,8 +25,8 @@ [x] Solve infinite loop that started happening after clicking into a SiteDetails with an AddEditRow component. [x] Abstract the Form for Add/Edit flag so that it can be re-used for both types (like a New/Edit registration page). [x] Ensure that all Components have PropTypes configured. -[] Design process for processing the Flag objects in an array on the Site Details page. - [] Create objects which accept a SiteID and a FlagIndex as a prop. That way there's no need to pass forward/back callbacks and pollute the SiteDetailsContainer. +[x] Design process for processing the Flag objects in an array on the Site Details page. + [x] Create objects which accept a SiteID and a FlagIndex as a prop. That way there's no need to pass forward/back callbacks and pollute the SiteDetailsContainer. [x] Come up with a way to un-select the current site by routing ing back to the main page. [] Refactor the Reducer for SELECT_SITE. It doesn't need to send a flag in the payload now because another action will be used for un-selecting. [x] Find out why Selector for getSelectedSiteIsLoaded is returning stale data from the state. diff --git a/app/js/components/ModalConfirm.jsx b/app/js/components/ModalConfirm.jsx new file mode 100644 index 0000000..dcf1a9b --- /dev/null +++ b/app/js/components/ModalConfirm.jsx @@ -0,0 +1,45 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import PropTypes from 'prop-types' + + +function ModalConfirm (props) { + + const modalWrapperCss = { + position: "fixed", + zIndex: 2, + left: 0, + top: 0, + width: "100%", + height: "100%", + backgroundColor: "rgba(0,0,0,0.4)" + } + + const modalContent = { + backgroundColor: "#eee", + margin: "20% auto", + padding: "3em", + border: "2px dashed black", + width: "70%", + textAlign: "center", + borderRadius: "1em" + } + + return (
+
+
{props.message}
+
+ + +
+
+
) +} + +ModalConfirm.propTypes = { + onCancel: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + message: PropTypes.string.isRequired +} + +export default ModalConfirm; diff --git a/app/js/components/SiteDetails.jsx b/app/js/components/SiteDetails.jsx index c526e35..325f6a0 100644 --- a/app/js/components/SiteDetails.jsx +++ b/app/js/components/SiteDetails.jsx @@ -44,7 +44,7 @@ function SiteDetails (props) { const commonHeader = (

{props.siteName}

-
Close Site Details
+ Close Site Details
{ props.isSaving && isSavingTag } { props.hasErrorSaving && hasSavingErrorTag } diff --git a/app/js/containers/AddFlagContainer.jsx b/app/js/containers/AddFlagContainer.jsx index c6737f3..cf388f0 100644 --- a/app/js/containers/AddFlagContainer.jsx +++ b/app/js/containers/AddFlagContainer.jsx @@ -41,10 +41,6 @@ class AddFlagContainer extends Component { this.props.dispatch(addFlag(this.props.siteId)) } - componentDidMount() { - - } - render() { if(this.props.showAddFlagForm){ diff --git a/app/js/containers/FlagRowContainer.jsx b/app/js/containers/FlagRowContainer.jsx index ac82c5a..c551950 100644 --- a/app/js/containers/FlagRowContainer.jsx +++ b/app/js/containers/FlagRowContainer.jsx @@ -3,11 +3,18 @@ import SiteList from '../components/SiteList' import React, { Component } from 'react'; import { saveSiteData } from '../actions/thunks' import { editFlag, editFlagCancel, confirmDeleteShow, confirmDeleteHide } from '../actions/actionObjects' -import { getShowAddFlagFormForSelectedSite, getSelectedSiteId, getSelectedSiteObj, getFlagIndexBeingEditedForSelectedSite } from '../selectors' import AddOrEditFlag from '../components/AddOrEditFlag' +import ModalConfirm from '../components/ModalConfirm' import FlagRow from '../components/FlagRow' import PropTypes from 'prop-types' +import {getShowAddFlagFormForSelectedSite, + getSelectedSiteId, + getSelectedSiteObj, + getFlagIndexBeingEditedForSelectedSite, + getFlagIndexWithDeleteModalForSelectedSite + } from '../selectors' + class FlagRowContainer extends Component { @@ -15,11 +22,12 @@ class FlagRowContainer extends Component { super(props); - this.handleUpdateFlag = this.handleUpdateFlag.bind(this); - this.handleCancelEdit = this.handleCancelEdit.bind(this); - this.handleShowEdit = this.handleShowEdit.bind(this); + this.handleUpdateFlag = this.handleUpdateFlag.bind(this); + this.handleCancelEdit = this.handleCancelEdit.bind(this); + this.handleShowEdit = this.handleShowEdit.bind(this); this.handleConfirmDeleteShow = this.handleConfirmDeleteShow.bind(this); this.handleConfirmDeleteHide = this.handleConfirmDeleteHide.bind(this); + this.handleDeleteFlagEntry = this.handleDeleteFlagEntry.bind(this); if(this.props.flagIndex >= this.props.siteObj.flags.length) throw new Error("Error in FlagRowContainer constructor. The Flag Index is out of bounds."); @@ -27,17 +35,32 @@ class FlagRowContainer extends Component { handleUpdateFlag(flagObj) { - var existingFlagsArr = this.props.siteObj.flags.concat(); + let existingFlagsArr = this.props.siteObj.flags.concat(); existingFlagsArr[this.props.flagIndex] = flagObj; - var updatedSiteObj = Object.assign({}, this.props.siteObj, { flags: existingFlagsArr }) + const updatedSiteObj = Object.assign({}, this.props.siteObj, { flags: existingFlagsArr }) // Get the existing Site Object and add just push the new Flag object onto its array. // The REST call must update the entire SiteObj and everything in it. this.props.dispatch(saveSiteData(this.props.siteId, updatedSiteObj)) } + handleDeleteFlagEntry() { + + if(this.props.flagIndexWithDeleteModal < 0) + throw new Error("A callback for a delete confirmation occured without a valid flagIndexWithDeleteModal prop."); + + // Make a copy of the array before deleting the entry. + let existingFlagsArr = this.props.siteObj.flags.concat(); + + existingFlagsArr.splice(this.props.flagIndexWithDeleteModal, 1) + + const updatedSiteObj = Object.assign({}, this.props.siteObj, { flags: existingFlagsArr }) + + this.props.dispatch(saveSiteData(this.props.siteId, updatedSiteObj)) + } + handleCancelEdit() { this.props.dispatch(editFlagCancel(this.props.siteId, this.props.flagIndex)) } @@ -54,21 +77,29 @@ class FlagRowContainer extends Component { this.props.dispatch(confirmDeleteHide(this.props.siteId, this.props.flagIndex)) } - componentDidMount() { - - } - render() { const currentFlagObj = this.props.siteObj.flags[this.props.flagIndex] + let deleteModalComponent + + if(this.props.flagIndexWithDeleteModal === this.props.flagIndex) + deleteModalComponent = ( + ) + const flagRowComponent = ( +
+ {deleteModalComponent} +
) // If these two match then it means that the given FlagRow is being edited. @@ -95,13 +126,15 @@ FlagRowContainer.propTypes = { siteId: PropTypes.string.isRequired, siteObj: PropTypes.object.isRequired, flagIndex: PropTypes.number.isRequired, - flagIndexBeingEdited: PropTypes.number.isRequired + flagIndexBeingEdited: PropTypes.number.isRequired, + flagIndexWithDeleteModal: PropTypes.number.isRequired } export default connect(state => ( { siteId: getSelectedSiteId(state), siteObj: getSelectedSiteObj(state), - flagIndexBeingEdited: getFlagIndexBeingEditedForSelectedSite(state) + flagIndexBeingEdited: getFlagIndexBeingEditedForSelectedSite(state), + flagIndexWithDeleteModal: getFlagIndexWithDeleteModalForSelectedSite(state) } ))(FlagRowContainer) \ No newline at end of file diff --git a/app/js/containers/SiteDetailsContainer.jsx b/app/js/containers/SiteDetailsContainer.jsx index fe6f61f..675dbf3 100644 --- a/app/js/containers/SiteDetailsContainer.jsx +++ b/app/js/containers/SiteDetailsContainer.jsx @@ -4,8 +4,7 @@ import React, { Component } from 'react'; import { fetchSiteData } from '../actions/thunks' import { selectSite } from '../actions/actionObjects' import PropTypes from 'prop-types' - -import { getSelectedSiteId, +import {getSelectedSiteId, getSelectedSiteName, getSelectedSiteFlags, getSelectedSiteIsLoading, diff --git a/app/js/containers/SiteListContainer.jsx b/app/js/containers/SiteListContainer.jsx index 7b06bdb..66d1a89 100644 --- a/app/js/containers/SiteListContainer.jsx +++ b/app/js/containers/SiteListContainer.jsx @@ -36,7 +36,8 @@ class SiteListContainer extends Component { } } -// The shape of the array will be validated within SiteList, the props are simply forwarded. +// The shape of the array objects will be validated within using a custom prop validator. +// It makes more sense to validate there because its the component which depends upon certain properties. SiteListContainer.propTypes = { sites: PropTypes.array.isRequired, sitesListIsLoading: PropTypes.bool.isRequired, diff --git a/app/js/reducers/index.js b/app/js/reducers/index.js index fc78d7d..cd0ba06 100644 --- a/app/js/reducers/index.js +++ b/app/js/reducers/index.js @@ -43,7 +43,9 @@ function sitesArr (state = [], action) { // If there's an error, leave the form(s) open so that the user can try again. siteArrCopy[indexOfSiteInArr].hasErrorSaving = true - siteArrCopy[indexOfSiteInArr].isSaving = false + + // If there's an error saving to the server then the modal window needs to be closed in order to see the error message. + siteArrCopy[indexOfSiteInArr].showConfirmDeleteForFlagIndex = undefined } else{ @@ -51,11 +53,17 @@ function sitesArr (state = [], action) { siteArrCopy[indexOfSiteInArr] = cloneDeep(siteObjectFromPayload) // Make sure to close the form since the update succeeded. + // But don't take it down in case of an error or the user could lose data before re-submitting. siteArrCopy[indexOfSiteInArr].showAddFlagForm = false + + // It's important to errase any of the indexes because if a Flag Entry was deleted before calling the Save API, the existing indexes could be out of bounds. siteArrCopy[indexOfSiteInArr].showEditFormForFlagIndex = undefined - siteArrCopy[indexOfSiteInArr].isSaving = false + siteArrCopy[indexOfSiteInArr].showConfirmDeleteForFlagIndex = undefined } + // Whether an error happened or not, it's no longer saving. + siteArrCopy[indexOfSiteInArr].isSaving = false + return siteArrCopy } @@ -64,7 +72,8 @@ function sitesArr (state = [], action) { // Ran into a little problem here because it's not possible to quickly get the ID of the "selected Site" because of the ways that the reducers were separated. // It wouldn't be efficient in production, but one solution is to loop through all of the array elements an reset any Open/Edit flags. // The solution is either to normalize the data or add a Root Reducer. - return state; + // Fow now, if you start to Edit or Add a Flag on a Site, the form will remain opened when you return. + return state } case actionTypes.SITES_LIST_RESPONSE: { @@ -109,6 +118,31 @@ function sitesArr (state = [], action) { return siteArrCopy } + case actionTypes.CONFIRM_DELETE_FLAG: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.show !== "boolean") + throw new Error("Error in reducer for CONFIRM_DELETE_FLAG. action.payload.show should be Boolean.") + + if(typeof action.payload.flagIndex !== "number") + throw new Error("Error in reducer for CONFIRM_DELETE_FLAG. action.payload.flagIndex should be Number.") + + if(!siteObj.flags[action.payload.flagIndex]) + throw new Error("Error in reducer for CONFIRM_DELETE_FLAG. The given flagIndex is out of bounds.") + + // If the Site Object stores a number on showConfirmDeleteForFlagIndex then it means that the Flag has a Delete Confirm modal opened. + if(action.payload.show) + siteObj.showConfirmDeleteForFlagIndex = action.payload.flagIndex + else + siteObj.showConfirmDeleteForFlagIndex = undefined + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj + + return siteArrCopy + } case actionTypes.ADD_FLAG: { diff --git a/app/js/selectors/index.js b/app/js/selectors/index.js index 32ea7bd..628264a 100644 --- a/app/js/selectors/index.js +++ b/app/js/selectors/index.js @@ -148,3 +148,18 @@ export const getFlagIndexBeingEditedForSelectedSite = createSelector( return selectedSiteIdObj.showEditFormForFlagIndex } ) + +// Returns -1 if the selected Site Object does not have a Delete Modal open. Otherwise the value corresponds to the Flag index which has the modal visible. +export const getFlagIndexWithDeleteModalForSelectedSite = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj || typeof selectedSiteIdObj.showConfirmDeleteForFlagIndex === "undefined") + return -1 + + if(typeof selectedSiteIdObj.showConfirmDeleteForFlagIndex !== "number" || !selectedSiteIdObj.flags[selectedSiteIdObj.showConfirmDeleteForFlagIndex]) + throw new Error("Error in getFlagIndexWithDeleteModalForSelectedSite. The Flag Index selected for editing is out of bounds.") + + return selectedSiteIdObj.showConfirmDeleteForFlagIndex + } +) From e8c68eb4e980202314aa9eb91da15df9bc8d20bf Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Wed, 30 Aug 2017 00:00:49 -0700 Subject: [PATCH 13/14] Updated remaining issues on Task List. --- Brian Task List.txt | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Brian Task List.txt b/Brian Task List.txt index 8e4994b..539a73b 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,5 +1,19 @@ +[] Unit Test with Mocha in order of importance + [] Reducers + [] Selectors + [] Action Creators + [] JS DOM + [] REST API + [x] Research Mockgoose. It looks like it requires MongoDB to be installed locally. + [] Configure an ENV variable within Unit Test and adapt REST service. + [] Since MongoDB is in Cloud, setup another "testDB" where the default data script can be loaded each time. + [] Modify the "defaultData" method to insert addtional "flags" within the array on each "site" document. + [] Microservices: Configure a consumer contract and Pact broker + +[] Integrate application with Web Components or React Components for the "Date Pickers" [x] Finish wiring up the DELETE actions and update the server through a POST. [x] Make sure that the reducers clear out any array indexes after the server responds because the positions may have changed. + [x] Create a generic a component that can be used for "Delete Flag". [x] Find out why the JSX List is not matching to the Filtered array (by date).... weird, weird, weird. [x] Reset the Edit/New form on the state after a successful update. @@ -17,6 +31,7 @@ [x] Install the Body Parser middle-ware for Express. [x] Find out why FETCH is doing a pre-flight request (i.e. OPTIONS whenever ApplicationType JSON is put in the headers) [x] Figure out a way to use BodyParser with "text/plain" or write a custom middleware to chunk the req.body. + [x] Add form validation to the AddOrEditFlag dumb component. [] Clean up all of the state properties on the individual Sites array when the SiteDetailList is closed within the Reducer (likely by brute force). For example, if the Add Flag form is open on a view, then it appears open when re-visiting the same site details page. [x] Fix weird warning that happens when canceling the AddFlag Form: "Form submission canceled because the form is not connected" @@ -27,6 +42,7 @@ [x] Ensure that all Components have PropTypes configured. [x] Design process for processing the Flag objects in an array on the Site Details page. [x] Create objects which accept a SiteID and a FlagIndex as a prop. That way there's no need to pass forward/back callbacks and pollute the SiteDetailsContainer. + [x] Come up with a way to un-select the current site by routing ing back to the main page. [] Refactor the Reducer for SELECT_SITE. It doesn't need to send a flag in the payload now because another action will be used for un-selecting. [x] Find out why Selector for getSelectedSiteIsLoaded is returning stale data from the state. @@ -37,6 +53,7 @@ [] Refactor codebase to get rid of inconsistent use of "siteList..." versus "sitesList". [x] Store the selected Site ID on the State after a Route change so that the Site Details can be memoized in various selectors. [x] Move the info about the SiteList, such as error, inProgress, etc. into its own Key on the state and combine reducers. + [x] Refactor the dispatch calls for "isLoading(false)" on SiteDeails Load/Save. The Reducers can infer when the action for the Response or Error is given. [] Find out why the ES7 "rest" operators is reporting a syntax error in the browser. Chrome works with the same syntax natively. Maybe it's an ESlint thing? [] Confirm that WebPack will strip out console.log() statements in production. @@ -44,15 +61,18 @@ [x] Figure out why MapStateToProps is not reflecting the state returned from the reducer in the Container method. [x] Explain why there seems to be 2 separate Redux stores. [x] Find out why the entry page (/app/js/app.js) is executed twice. + [x] Add CORS support for the REST service. [x] Fix error that started showing up: Module not found: Error: Can't resolve 'whatwg-fetch' in ... [x] I had to take out the Pollyfill I created for "fetch" in the WebPack config. Maybe I need to import another library like Axios or manually pollyfill? + [x] Create a Container component for the SiteList that will take care of loading the data from the API if the state is empty. [] Setup Decorators to validate function inputs, like TypeScript. [x] Stub out actions with basic Thunk workflow. [x] Decide on convention for Action types. SFA [x] How to deal with Errors on PUT "SiteData" which has a SiteID associated with the Payload. [x] Create Thunk Aysnc Actions for GetSiteList, GetSiteData(SiteID), SaveSiteData(siteId) + [x] Decide on a process for loading data from the server and saving mutations. [x] Design shape of reducers [] Find a way to keep the "key" entries unique on components within Site Details view. @@ -68,12 +88,6 @@ [x] Verify that the document exists first, no upserting. [x] Validate contents of the Site document. [] Setup Mongoose to validate the Site Update route. - [] Unit test with Mocha/Chai - [x] Research Mockgoose. It looks like it requires MongoDB to be installed locally. - [] Configure an ENV variable within Unit Test and adapt REST service. - [] Since MongoDB is in Cloud, setup another "testDB" where the default data script can be loaded each time. - [] Modify the "defaultData" method to insert addtional "flags" within the array on each "site" document. - [] Configure a consumer contract and Pact broker for Microservices [x] Configure server-side framework [x] Install Express From 1bfb42f8c0080e159cc7ee6bc486e4b8cac5f8ec Mon Sep 17 00:00:00 2001 From: Brian Piere Date: Sun, 10 Sep 2017 19:13:29 -0700 Subject: [PATCH 14/14] Added Mocha Unit Testing. --- Brian Task List.txt | 2 ++ app/js/components/ModalConfirm.jsx | 6 ++--- app/test/ModalConfirm.spec.js | 41 ++++++++++++++++++++++++++++++ app/test/browser.js | 23 +++++++++++++++++ package.json | 13 +++++++++- 5 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 app/test/ModalConfirm.spec.js create mode 100644 app/test/browser.js diff --git a/Brian Task List.txt b/Brian Task List.txt index 539a73b..7b6accb 100644 --- a/Brian Task List.txt +++ b/Brian Task List.txt @@ -1,4 +1,6 @@ [] Unit Test with Mocha in order of importance + [] Components + [x] ModalConfirm [] Reducers [] Selectors [] Action Creators diff --git a/app/js/components/ModalConfirm.jsx b/app/js/components/ModalConfirm.jsx index dcf1a9b..7575e85 100644 --- a/app/js/components/ModalConfirm.jsx +++ b/app/js/components/ModalConfirm.jsx @@ -27,10 +27,10 @@ function ModalConfirm (props) { return (
-
{props.message}
+
{props.message}
- - + +
) diff --git a/app/test/ModalConfirm.spec.js b/app/test/ModalConfirm.spec.js new file mode 100644 index 0000000..95dbb8a --- /dev/null +++ b/app/test/ModalConfirm.spec.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import {expect} from 'chai'; +import sinon from 'sinon'; + +import ModalConfirm from '../js/components/ModalConfirm'; + +describe('', () => { + + let wrapper + + let onConfirm + let onCancel + + beforeEach(() => { + onConfirm = sinon.spy() + onCancel = sinon.spy() + + wrapper = mount() + }) + + it('Modal renders Confirm and Cancel buttons', () => { + expect(wrapper.find('.button--confirm').length).to.equal(1) + expect(wrapper.find('.button--cancel').length).to.equal(1) + }) + + it('Modal Confirm Button Click should invoke onConfirm callback in props', () => { + wrapper.find('.button--confirm').at(0).simulate('click') + expect(onConfirm.calledOnce).to.equal(true); + }) + + it('Modal Cancel Button Click should invoke onCancel callback in props', () => { + wrapper.find('.button--cancel').at(0).simulate('click') + expect(onCancel.calledOnce).to.equal(true); + }) + + it('Modal Window should contain given text within message prop.', () => { + expect(wrapper.find('.container--message').first().text()).to.equal('Testing Message'); + }) + +}); diff --git a/app/test/browser.js b/app/test/browser.js new file mode 100644 index 0000000..649d211 --- /dev/null +++ b/app/test/browser.js @@ -0,0 +1,23 @@ +require('babel-core/register')(); + +var jsdom = require('jsdom'); +const { JSDOM } = jsdom; + +var exposedProperties = ['window', 'navigator', 'document']; + +const { document } = (new JSDOM('')).window; +global.document = document; + +global.window = document.defaultView; +Object.keys(document.defaultView).forEach((property) => { + if (typeof global[property] === 'undefined') { + exposedProperties.push(property); + global[property] = document.defaultView[property]; + } +}); + +global.navigator = { + userAgent: 'node.js' +}; + +documentRef = document; \ No newline at end of file diff --git a/package.json b/package.json index 5de8103..2cb363c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "GSTV Coding Excercise", "main": "index.js", "scripts": { - "test": "mocha", + "test": "mocha app/test/browser.js app/**/*.spec.js", "dev": "node ./node_modules/webpack-dev-server/bin/webpack-dev-server.js --hot --inline --history-api-fallback" }, "repository": { @@ -21,6 +21,7 @@ }, "homepage": "https://github.com/BrianPiere/full-stack-coding-exercise#readme", "dependencies": { + "enzyme": "^2.9.1", "es6-promise": "^4.1.1", "express": "^4.15.4", "fetch-everywhere": "^1.0.5", @@ -42,13 +43,23 @@ "babel-loader": "^7.1.2", "babel-preset-es2015": "^6.24.1", "babel-preset-react": "^6.24.1", + "chai": "^4.1.2", + "enzyme": "^2.9.1", "eslint": "^4.3.0", "eslint-config-airbnb": "^15.1.0", "eslint-plugin-import": "^2.7.0", "eslint-plugin-jsx-a11y": "^5.1.1", "eslint-plugin-react": "^7.1.0", "file-loader": "^0.11.2", + "jest": "^21.0.2", + "jsdom": "11.2.0", + "jsdom-global": "3.0.2", + "mocha": "^3.5.0", + "react-addons-test-utils": "^15.6.0", + "react-dom": "^15.6.1", "react-hot-loader": "^1.3.1", + "react-test-renderer": "^15.6.1", + "sinon": "^3.2.1", "webpack": "^3.5.5", "webpack-dev-server": "^2.7.1" }