├── api ├── .gitignore ├── .eslintrc ├── about.js ├── sample.env ├── server.js ├── graphql_date.js ├── db.js ├── scripts │ ├── generate_data.mongo.js │ ├── init.mongo.js │ └── trymongo.js ├── package.json ├── api_handler.js ├── schema.graphql ├── auth.js └── issue.js ├── ui ├── .eslintrc ├── public │ └── bootstrap ├── src │ ├── store.js │ ├── NotFound.jsx │ ├── UserContext.js │ ├── .eslintrc │ ├── IssueDetail.jsx │ ├── Contents.jsx │ ├── routes.js │ ├── Toast.jsx │ ├── TextInput.jsx │ ├── About.jsx │ ├── NumInput.jsx │ ├── graphQLFetch.js │ ├── Search.jsx │ ├── withToast.jsx │ ├── DateInput.jsx │ ├── IssueAddNavItem.jsx │ ├── Page.jsx │ ├── IssueReport.jsx │ ├── IssueTable.jsx │ ├── SignInNavItem.jsx │ ├── IssueFilter.jsx │ ├── IssueList.jsx │ └── IssueEdit.jsx ├── .gitignore ├── server │ ├── .eslintrc │ ├── template.js │ ├── render.jsx │ └── uiserver.js ├── browser │ ├── .eslintrc │ └── App.jsx ├── webpack.serverHMR.js ├── sample.env ├── webpack.config.js └── package.json ├── .gitignore ├── mongoCommands.md ├── commands.md └── README.md /api/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /ui/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | } 4 | -------------------------------------------------------------------------------- /ui/public/bootstrap: -------------------------------------------------------------------------------- 1 | ../node_modules/bootstrap/dist -------------------------------------------------------------------------------- /ui/src/store.js: -------------------------------------------------------------------------------- 1 | const store = {}; 2 | 3 | export default store; 4 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .env 4 | public/*.js 5 | public/*.js.map 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | ui/public/*.js 4 | ui/public/*.map 5 | .env 6 | ui/dist 7 | -------------------------------------------------------------------------------- /ui/src/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function NotFound() { 4 | return

Page Not Found

; 5 | } 6 | 7 | export default NotFound; 8 | -------------------------------------------------------------------------------- /api/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "node": "true" 5 | }, 6 | rules: { 7 | "no-console": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/UserContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const UserContext = React.createContext({ 4 | signedIn: false, 5 | }); 6 | 7 | export default UserContext; 8 | -------------------------------------------------------------------------------- /ui/server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "rules": { 6 | "no-console": "off", 7 | "import/extensions": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/browser/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | rules: { 6 | "import/extensions": [ 'error', 'always', { ignorePackages: true } ], 7 | "react/prop-types": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true 5 | }, 6 | "rules": { 7 | "import/extensions": [ "error", "always", { "ignorePackages": true } ], 8 | "react/prop-types": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/IssueDetail.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function IssueDetail({ issue }) { 4 | if (issue) { 5 | return ( 6 |
7 |

Description

8 |
{issue.description}
9 |
10 | ); 11 | } 12 | return null; 13 | } 14 | -------------------------------------------------------------------------------- /api/about.js: -------------------------------------------------------------------------------- 1 | const { mustBeSignedIn } = require('./auth.js'); 2 | 3 | let aboutMessage = 'Issue Tracker API v1.0'; 4 | 5 | function setMessage(_, { message }) { 6 | aboutMessage = message; 7 | return aboutMessage; 8 | } 9 | 10 | function getMessage() { 11 | return aboutMessage; 12 | } 13 | 14 | module.exports = { getMessage, setMessage: mustBeSignedIn(setMessage) }; 15 | -------------------------------------------------------------------------------- /ui/src/Contents.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route, Redirect } from 'react-router-dom'; 3 | 4 | import routes from './routes.js'; 5 | 6 | export default function Contents() { 7 | return ( 8 | 9 | 10 | {routes.map(attrs => )} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/webpack.serverHMR.js: -------------------------------------------------------------------------------- 1 | /* 2 | eslint-disable import/no-extraneous-dependencies 3 | */ 4 | const webpack = require('webpack'); 5 | const merge = require('webpack-merge'); 6 | const serverConfig = require('./webpack.config.js')[1]; 7 | 8 | module.exports = merge(serverConfig, { 9 | entry: { server: ['./node_modules/webpack/hot/poll?1000'] }, 10 | plugins: [ 11 | new webpack.HotModuleReplacementPlugin(), 12 | ], 13 | }); 14 | -------------------------------------------------------------------------------- /ui/src/routes.js: -------------------------------------------------------------------------------- 1 | import IssueList from './IssueList.jsx'; 2 | import IssueReport from './IssueReport.jsx'; 3 | import IssueEdit from './IssueEdit.jsx'; 4 | import About from './About.jsx'; 5 | import NotFound from './NotFound.jsx'; 6 | 7 | const routes = [ 8 | { path: '/issues/:id?', component: IssueList }, 9 | { path: '/edit/:id', component: IssueEdit }, 10 | { path: '/report', component: IssueReport }, 11 | { path: '/about', component: About }, 12 | { path: '*', component: NotFound }, 13 | ]; 14 | 15 | export default routes; 16 | -------------------------------------------------------------------------------- /api/sample.env: -------------------------------------------------------------------------------- 1 | ## DB 2 | # Local 3 | DB_URL=mongodb://localhost/issuetracker 4 | 5 | # Atlas - replace UUU: user, PPP: password, XXX: hostname 6 | # DB_URL=mongodb+srv://UUU:PPP@XXX.mongodb.net/issuetracker?retryWrites=true 7 | 8 | # mLab - replace UUU: user, PPP: password, XXX: hostname, YYY: port 9 | # DB_URL=mongodb://UUU:PPP@XXX.mlab.com:YYY/issuetracker 10 | 11 | 12 | ## Server Port 13 | PORT=3000 14 | 15 | ## Enable CORS (default: true) 16 | # ENABLE_CORS=false 17 | 18 | UI_SERVER_ORIGIN=http://ui.promernstack.com:8000 19 | 20 | COOKIE_DOMAIN=promernstack.com 21 | -------------------------------------------------------------------------------- /ui/browser/App.jsx: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { BrowserRouter as Router } from 'react-router-dom'; 5 | 6 | import Page from '../src/Page.jsx'; 7 | import store from '../src/store.js'; 8 | 9 | /* eslint-disable no-underscore-dangle */ 10 | store.initialData = window.__INITIAL_DATA__; 11 | store.userData = window.__USER_DATA__; 12 | 13 | const element = ( 14 | 15 | 16 | 17 | ); 18 | 19 | ReactDOM.hydrate(element, document.getElementById('contents')); 20 | 21 | if (module.hot) { 22 | module.hot.accept(); 23 | } 24 | -------------------------------------------------------------------------------- /ui/sample.env: -------------------------------------------------------------------------------- 1 | PORT=8000 2 | # ENABLE_HMR=true 3 | GOOGLE_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com 4 | 5 | # Regular config 6 | # UI_API_ENDPOINT=http://localhost:3000/graphql 7 | # UI_AUTH_ENDPOINT=http://localhost:3000/auth 8 | 9 | # Regular config with domains 10 | UI_API_ENDPOINT=http://api.promernstack.com:3000/graphql 11 | UI_AUTH_ENDPOINT=http://api.promernstack.com:3000/auth 12 | 13 | # Proxy Config 14 | # UI_API_ENDPOINT=http://localhost:8000/graphql 15 | # UI_AUTH_ENDPOINT=http://localhost:8000/auth 16 | # API_PROXY_TARGET=http://localhost:3000 17 | # UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql 18 | -------------------------------------------------------------------------------- /api/server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const cookieParser = require('cookie-parser'); 4 | 5 | const { connectToDb } = require('./db.js'); 6 | const { installHandler } = require('./api_handler.js'); 7 | const auth = require('./auth.js'); 8 | 9 | const app = express(); 10 | 11 | app.use(cookieParser()); 12 | app.use('/auth', auth.routes); 13 | 14 | installHandler(app); 15 | 16 | const port = process.env.PORT || 3000; 17 | 18 | (async function start() { 19 | try { 20 | await connectToDb(); 21 | app.listen(port, () => { 22 | console.log(`API server started on port ${port}`); 23 | }); 24 | } catch (err) { 25 | console.log('ERROR:', err); 26 | } 27 | }()); 28 | -------------------------------------------------------------------------------- /api/graphql_date.js: -------------------------------------------------------------------------------- 1 | const { GraphQLScalarType } = require('graphql'); 2 | const { Kind } = require('graphql/language'); 3 | 4 | const GraphQLDate = new GraphQLScalarType({ 5 | name: 'GraphQLDate', 6 | description: 'A Date() type in GraphQL as a scalar', 7 | serialize(value) { 8 | return value.toISOString(); 9 | }, 10 | parseValue(value) { 11 | const dateValue = new Date(value); 12 | return Number.isNaN(dateValue.getTime()) ? undefined : dateValue; 13 | }, 14 | parseLiteral(ast) { 15 | if (ast.kind === Kind.STRING) { 16 | const value = new Date(ast.value); 17 | return Number.isNaN(value.getTime()) ? undefined : value; 18 | } 19 | return undefined; 20 | }, 21 | }); 22 | 23 | module.exports = GraphQLDate; 24 | -------------------------------------------------------------------------------- /api/db.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { MongoClient } = require('mongodb'); 3 | 4 | let db; 5 | 6 | async function connectToDb() { 7 | const url = process.env.DB_URL || 'mongodb://localhost/issuetracker'; 8 | const client = new MongoClient(url, { useNewUrlParser: true }); 9 | await client.connect(); 10 | console.log('Connected to MongoDB at', url); 11 | db = client.db(); 12 | } 13 | 14 | async function getNextSequence(name) { 15 | const result = await db.collection('counters').findOneAndUpdate( 16 | { _id: name }, 17 | { $inc: { current: 1 } }, 18 | { returnOriginal: false }, 19 | ); 20 | return result.value.current; 21 | } 22 | 23 | function getDb() { 24 | return db; 25 | } 26 | 27 | module.exports = { connectToDb, getNextSequence, getDb }; 28 | -------------------------------------------------------------------------------- /ui/src/Toast.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert, Collapse } from 'react-bootstrap'; 3 | 4 | export default class Toast extends React.Component { 5 | componentDidUpdate() { 6 | const { showing, onDismiss } = this.props; 7 | if (showing) { 8 | clearTimeout(this.dismissTimer); 9 | this.dismissTimer = setTimeout(onDismiss, 5000); 10 | } 11 | } 12 | 13 | componentWillUnmount() { 14 | clearTimeout(this.dismissTimer); 15 | } 16 | 17 | render() { 18 | const { 19 | showing, bsStyle, onDismiss, children, 20 | } = this.props; 21 | return ( 22 | 23 |
27 | 28 | {children} 29 | 30 |
31 |
32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ui/src/TextInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function format(text) { 4 | return text != null ? text : ''; 5 | } 6 | 7 | function unformat(text) { 8 | return text.trim().length === 0 ? null : text; 9 | } 10 | 11 | export default class TextInput extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | this.state = { value: format(props.value) }; 15 | this.onBlur = this.onBlur.bind(this); 16 | this.onChange = this.onChange.bind(this); 17 | } 18 | 19 | onChange(e) { 20 | this.setState({ value: e.target.value }); 21 | } 22 | 23 | onBlur(e) { 24 | const { onChange } = this.props; 25 | const { value } = this.state; 26 | onChange(e, unformat(value)); 27 | } 28 | 29 | render() { 30 | const { value } = this.state; 31 | const { tag = 'input', ...props } = this.props; 32 | return React.createElement(tag, { 33 | ...props, 34 | value, 35 | onBlur: this.onBlur, 36 | onChange: this.onChange, 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ui/src/About.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import store from './store.js'; 3 | import graphQLFetch from './graphQLFetch.js'; 4 | 5 | export default class About extends React.Component { 6 | static async fetchData() { 7 | const data = await graphQLFetch('query {about}'); 8 | return data; 9 | } 10 | 11 | constructor(props) { 12 | super(props); 13 | const apiAbout = store.initialData ? store.initialData.about : null; 14 | delete store.initialData; 15 | this.state = { apiAbout }; 16 | } 17 | 18 | async componentDidMount() { 19 | const { apiAbout } = this.state; 20 | if (apiAbout == null) { 21 | const data = await About.fetchData(); 22 | this.setState({ apiAbout: data.about }); 23 | } 24 | } 25 | 26 | render() { 27 | const { apiAbout } = this.state; 28 | return ( 29 |
30 |

Issue Tracker version 0.9

31 |

32 | {apiAbout} 33 |

34 |
35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/server/template.js: -------------------------------------------------------------------------------- 1 | import serialize from 'serialize-javascript'; 2 | 3 | export default function template(body, initialData, userData) { 4 | return ` 5 | 6 | 7 | 8 | 9 | Pro MERN Stack 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 |
${body}
24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | `; 36 | } 37 | -------------------------------------------------------------------------------- /ui/src/NumInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function format(num) { 4 | return num != null ? num.toString() : ''; 5 | } 6 | 7 | function unformat(str) { 8 | const val = parseInt(str, 10); 9 | return Number.isNaN(val) ? null : val; 10 | } 11 | 12 | export default class NumInput extends React.Component { 13 | constructor(props) { 14 | super(props); 15 | this.state = { value: format(props.value) }; 16 | this.onBlur = this.onBlur.bind(this); 17 | this.onChange = this.onChange.bind(this); 18 | } 19 | 20 | onChange(e) { 21 | if (e.target.value.match(/^\d*$/)) { 22 | this.setState({ value: e.target.value }); 23 | } 24 | } 25 | 26 | onBlur(e) { 27 | const { onChange } = this.props; 28 | const { value } = this.state; 29 | onChange(e, unformat(value)); 30 | } 31 | 32 | render() { 33 | const { value } = this.state; 34 | return ( 35 | 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /api/scripts/generate_data.mongo.js: -------------------------------------------------------------------------------- 1 | /* global db print */ 2 | /* eslint no-restricted-globals: "off" */ 3 | 4 | const owners = ['Ravan', 'Eddie', 'Pieta', 'Parvati', 'Victor']; 5 | const statuses = ['New', 'Assigned', 'Fixed', 'Closed']; 6 | 7 | const initialCount = db.issues.count(); 8 | 9 | for (let i = 0; i < 100; i += 1) { 10 | const randomCreatedDate = (new Date()) 11 | - Math.floor(Math.random() * 60) * 1000 * 60 * 60 * 24; 12 | const created = new Date(randomCreatedDate); 13 | const randomDueDate = (new Date()) 14 | - Math.floor(Math.random() * 60) * 1000 * 60 * 60 * 24; 15 | const due = new Date(randomDueDate); 16 | 17 | const owner = owners[Math.floor(Math.random() * 5)]; 18 | const status = statuses[Math.floor(Math.random() * 4)]; 19 | const effort = Math.ceil(Math.random() * 20); 20 | const title = `Lorem ipsum dolor sit amet, ${i}`; 21 | const id = initialCount + i + 1; 22 | 23 | const issue = { 24 | id, title, created, due, owner, status, effort, 25 | }; 26 | 27 | db.issues.insertOne(issue); 28 | } 29 | 30 | const count = db.issues.count(); 31 | db.counters.update({ _id: 'issues' }, { $set: { current: count } }); 32 | 33 | print('New issue count:', count); 34 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pro-mern-stack-2-api", 3 | "version": "1.0.0", 4 | "description": "Pro MERN Stack (2nd Edition) API", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "10.x", 8 | "npm": "6.x" 9 | }, 10 | "scripts": { 11 | "start": "nodemon -e js,graphql -w . -w .env server.js", 12 | "lint": "eslint .", 13 | "test": "echo \"Error: no test specified\" && exit 1" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/vasansr/pro-mern-stack-2.git" 18 | }, 19 | "author": "vasan.promern@gmail.com", 20 | "license": "ISC", 21 | "homepage": "https://github.com/vasansr/pro-mern-stack-2", 22 | "dependencies": { 23 | "apollo-server-express": "^2.3.1", 24 | "body-parser": "^1.18.3", 25 | "cookie-parser": "^1.4.3", 26 | "cors": "^2.8.5", 27 | "dotenv": "^6.2.0", 28 | "express": "^4.16.4", 29 | "google-auth-library": "^2.0.2", 30 | "graphql": "^0.13.2", 31 | "jsonwebtoken": "^8.4.0", 32 | "mongodb": "^3.1.10", 33 | "nodemon": "^1.18.9" 34 | }, 35 | "devDependencies": { 36 | "eslint": "^5.12.0", 37 | "eslint-config-airbnb-base": "^13.1.0", 38 | "eslint-plugin-import": "^2.14.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ui/server/render.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import { StaticRouter, matchPath } from 'react-router-dom'; 4 | 5 | import Page from '../src/Page.jsx'; 6 | import template from './template.js'; 7 | import store from '../src/store.js'; 8 | import routes from '../src/routes.js'; 9 | 10 | async function render(req, res) { 11 | const activeRoute = routes.find( 12 | route => matchPath(req.path, route), 13 | ); 14 | 15 | let initialData; 16 | if (activeRoute && activeRoute.component.fetchData) { 17 | const match = matchPath(req.path, activeRoute); 18 | const index = req.url.indexOf('?'); 19 | const search = index !== -1 ? req.url.substr(index) : null; 20 | initialData = await activeRoute.component 21 | .fetchData(match, search, req.headers.cookie); 22 | } 23 | 24 | const userData = await Page.fetchData(req.headers.cookie); 25 | 26 | store.initialData = initialData; 27 | store.userData = userData; 28 | 29 | const context = {}; 30 | const element = ( 31 | 32 | 33 | 34 | ); 35 | const body = ReactDOMServer.renderToString(element); 36 | 37 | if (context.url) { 38 | res.redirect(301, context.url); 39 | } else { 40 | res.send(template(body, initialData, userData)); 41 | } 42 | } 43 | 44 | export default render; 45 | -------------------------------------------------------------------------------- /ui/src/graphQLFetch.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch'; 2 | 3 | const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d'); 4 | 5 | function jsonDateReviver(key, value) { 6 | if (dateRegex.test(value)) return new Date(value); 7 | return value; 8 | } 9 | 10 | export default async function 11 | graphQLFetch(query, variables = {}, showError = null, cookie = null) { 12 | const apiEndpoint = (__isBrowser__) // eslint-disable-line no-undef 13 | ? window.ENV.UI_API_ENDPOINT 14 | : process.env.UI_SERVER_API_ENDPOINT; 15 | try { 16 | const headers = { 'Content-Type': 'application/json' }; 17 | if (cookie) headers.Cookie = cookie; 18 | const response = await fetch(apiEndpoint, { 19 | method: 'POST', 20 | credentials: 'include', 21 | headers, 22 | body: JSON.stringify({ query, variables }), 23 | }); 24 | const body = await response.text(); 25 | const result = JSON.parse(body, jsonDateReviver); 26 | 27 | if (result.errors) { 28 | const error = result.errors[0]; 29 | if (error.extensions.code === 'BAD_USER_INPUT') { 30 | const details = error.extensions.exception.errors.join('\n '); 31 | if (showError) showError(`${error.message}:\n ${details}`); 32 | } else if (showError) { 33 | showError(`${error.extensions.code}: ${error.message}`); 34 | } 35 | } 36 | return result.data; 37 | } catch (e) { 38 | if (showError) showError(`Error in sending data to server: ${e.message}`); 39 | return null; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ui/src/Search.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SelectAsync from 'react-select/lib/Async'; // eslint-disable-line 3 | import { withRouter } from 'react-router-dom'; 4 | 5 | import graphQLFetch from './graphQLFetch.js'; 6 | import withToast from './withToast.jsx'; 7 | 8 | class Search extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | 12 | this.onChangeSelection = this.onChangeSelection.bind(this); 13 | this.loadOptions = this.loadOptions.bind(this); 14 | } 15 | 16 | onChangeSelection({ value }) { 17 | const { history } = this.props; 18 | history.push(`/edit/${value}`); 19 | } 20 | 21 | async loadOptions(term) { 22 | if (term.length < 3) return []; 23 | const query = `query issueList($search: String) { 24 | issueList(search: $search) { 25 | issues {id title} 26 | } 27 | }`; 28 | 29 | const { showError } = this.props; 30 | const data = await graphQLFetch(query, { search: term }, showError); 31 | return data.issueList.issues.map(issue => ({ 32 | label: `#${issue.id}: ${issue.title}`, value: issue.id, 33 | })); 34 | } 35 | 36 | render() { 37 | return ( 38 | true} 43 | onChange={this.onChangeSelection} 44 | components={{ DropdownIndicator: null }} 45 | /> 46 | ); 47 | } 48 | } 49 | 50 | export default withRouter(withToast(Search)); 51 | -------------------------------------------------------------------------------- /ui/src/withToast.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Toast from './Toast.jsx'; 3 | 4 | export default function withToast(OriginalComponent) { 5 | return class ToastWrapper extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | toastVisible: false, toastMessage: '', toastType: 'success', 10 | }; 11 | this.showSuccess = this.showSuccess.bind(this); 12 | this.showError = this.showError.bind(this); 13 | this.dismissToast = this.dismissToast.bind(this); 14 | } 15 | 16 | showSuccess(message) { 17 | this.setState({ toastVisible: true, toastMessage: message, toastType: 'success' }); 18 | } 19 | 20 | showError(message) { 21 | this.setState({ toastVisible: true, toastMessage: message, toastType: 'danger' }); 22 | } 23 | 24 | dismissToast() { 25 | this.setState({ toastVisible: false }); 26 | } 27 | 28 | render() { 29 | const { toastType, toastVisible, toastMessage } = this.state; 30 | return ( 31 | 32 | 38 | 43 | {toastMessage} 44 | 45 | 46 | ); 47 | } 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /api/api_handler.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | require('dotenv').config(); 3 | const { ApolloServer } = require('apollo-server-express'); 4 | 5 | const GraphQLDate = require('./graphql_date.js'); 6 | const about = require('./about.js'); 7 | const issue = require('./issue.js'); 8 | const auth = require('./auth.js'); 9 | 10 | const resolvers = { 11 | Query: { 12 | about: about.getMessage, 13 | user: auth.resolveUser, 14 | issueList: issue.list, 15 | issue: issue.get, 16 | issueCounts: issue.counts, 17 | }, 18 | Mutation: { 19 | setAboutMessage: about.setMessage, 20 | issueAdd: issue.add, 21 | issueUpdate: issue.update, 22 | issueDelete: issue.delete, 23 | issueRestore: issue.restore, 24 | }, 25 | GraphQLDate, 26 | }; 27 | 28 | function getContext({ req }) { 29 | const user = auth.getUser(req); 30 | return { user }; 31 | } 32 | 33 | const server = new ApolloServer({ 34 | typeDefs: fs.readFileSync('schema.graphql', 'utf-8'), 35 | resolvers, 36 | context: getContext, 37 | formatError: (error) => { 38 | console.log(error); 39 | return error; 40 | }, 41 | playground: true, 42 | introspection: true, 43 | }); 44 | 45 | function installHandler(app) { 46 | const enableCors = (process.env.ENABLE_CORS || 'true') === 'true'; 47 | console.log('CORS setting:', enableCors); 48 | let cors; 49 | if (enableCors) { 50 | const origin = process.env.UI_SERVER_ORIGIN || 'http://localhost:8000'; 51 | const methods = 'POST'; 52 | cors = { origin, methods, credentials: true }; 53 | } else { 54 | cors = 'false'; 55 | } 56 | server.applyMiddleware({ app, path: '/graphql', cors }); 57 | } 58 | 59 | module.exports = { installHandler }; 60 | -------------------------------------------------------------------------------- /ui/src/DateInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function displayFormat(date) { 4 | return (date != null) ? date.toDateString() : ''; 5 | } 6 | 7 | function editFormat(date) { 8 | return (date != null) ? date.toISOString().substr(0, 10) : ''; 9 | } 10 | 11 | function unformat(str) { 12 | const val = new Date(str); 13 | return Number.isNaN(val.getTime()) ? null : val; 14 | } 15 | 16 | export default class DateInput extends React.Component { 17 | constructor(props) { 18 | super(props); 19 | this.state = { 20 | value: editFormat(props.value), 21 | focused: false, 22 | valid: true, 23 | }; 24 | this.onFocus = this.onFocus.bind(this); 25 | this.onBlur = this.onBlur.bind(this); 26 | this.onChange = this.onChange.bind(this); 27 | } 28 | 29 | onFocus() { 30 | this.setState({ focused: true }); 31 | } 32 | 33 | onBlur(e) { 34 | const { value, valid: oldValid } = this.state; 35 | const { onValidityChange, onChange } = this.props; 36 | const dateValue = unformat(value); 37 | const valid = value === '' || dateValue != null; 38 | if (valid !== oldValid && onValidityChange) { 39 | onValidityChange(e, valid); 40 | } 41 | this.setState({ focused: false, valid }); 42 | if (valid) onChange(e, dateValue); 43 | } 44 | 45 | onChange(e) { 46 | if (e.target.value.match(/^[\d-]*$/)) { 47 | this.setState({ value: e.target.value }); 48 | } 49 | } 50 | 51 | render() { 52 | const { valid, focused, value } = this.state; 53 | const { value: origValue, onValidityChange, ...props } = this.props; 54 | const displayValue = (focused || !valid) ? value 55 | : displayFormat(origValue); 56 | return ( 57 | 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /api/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar GraphQLDate 2 | 3 | enum StatusType { 4 | New 5 | Assigned 6 | Fixed 7 | Closed 8 | } 9 | 10 | type Issue { 11 | _id: ID! 12 | id: Int! 13 | title: String! 14 | status: StatusType! 15 | owner: String 16 | effort: Int 17 | created: GraphQLDate! 18 | due: GraphQLDate 19 | description: String 20 | } 21 | 22 | type IssueCounts { 23 | owner: String! 24 | New: Int 25 | Assigned: Int 26 | Fixed: Int 27 | Closed: Int 28 | } 29 | 30 | type IssueListWithPages { 31 | issues: [Issue!]! 32 | pages: Int 33 | } 34 | 35 | type User { 36 | signedIn: Boolean! 37 | givenName: String 38 | name: String 39 | email: String 40 | } 41 | 42 | "Toned down Issue, used as inputs, without server generated values." 43 | input IssueInputs { 44 | title: String! 45 | "Optional, if not supplied, will be set to 'New'" 46 | status: StatusType = New 47 | owner: String 48 | effort: Int 49 | due: GraphQLDate 50 | description: String 51 | } 52 | 53 | """Inputs for issueUpdate: all are optional. Whichever is specified will 54 | be set to the given value, undefined fields will remain unmodified.""" 55 | input IssueUpdateInputs { 56 | title: String 57 | status: StatusType 58 | owner: String 59 | effort: Int 60 | due: GraphQLDate 61 | description: String 62 | } 63 | 64 | ##### Top level declarations 65 | 66 | type Query { 67 | about: String! 68 | user: User! 69 | issueList( 70 | status: StatusType 71 | effortMin: Int 72 | effortMax: Int 73 | search: String 74 | page: Int = 1 75 | ): IssueListWithPages 76 | issue(id: Int!): Issue! 77 | issueCounts( 78 | status: StatusType 79 | effortMin: Int 80 | effortMax: Int 81 | ): [IssueCounts!]! 82 | } 83 | 84 | type Mutation { 85 | setAboutMessage(message: String!): String 86 | issueAdd(issue: IssueInputs!): Issue! 87 | issueUpdate(id: Int!, changes: IssueUpdateInputs!): Issue! 88 | issueDelete(id: Int!): Boolean! 89 | issueRestore(id: Int!): Boolean! 90 | } 91 | -------------------------------------------------------------------------------- /api/scripts/init.mongo.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Run using the mongo shell. For remote databases, ensure that the 3 | * connection string is supplied in the command line. For example: 4 | * localhost: 5 | * mongo issuetracker scripts/init.mongo.js 6 | * Atlas: 7 | * mongo mongodb+srv://user:pwd@xxx.mongodb.net/issuetracker scripts/init.mongo.js 8 | * MLab: 9 | * mongo mongodb://user:pwd@xxx.mlab.com:33533/issuetracker scripts/init.mongo.js 10 | */ 11 | 12 | /* global db print */ 13 | /* eslint no-restricted-globals: "off" */ 14 | 15 | db.issues.remove({}); 16 | db.deleted_issues.remove({}); 17 | 18 | const issuesDB = [ 19 | { 20 | id: 1, 21 | status: 'New', 22 | owner: 'Ravan', 23 | effort: 5, 24 | created: new Date('2019-01-15'), 25 | due: undefined, 26 | title: 'Error in console when clicking Add', 27 | description: 'Steps to recreate the problem:' 28 | + '\n1. Refresh the browser.' 29 | + '\n2. Select "New" in the filter' 30 | + '\n3. Refresh the browser again. Note the warning in the console:' 31 | + '\n Warning: Hash history cannot PUSH the same path; a new entry' 32 | + '\n will not be added to the history stack' 33 | + '\n4. Click on Add.' 34 | + '\n5. There is an error in console, and add doesn\'t work.', 35 | }, 36 | { 37 | id: 2, 38 | status: 'Assigned', 39 | owner: 'Eddie', 40 | effort: 14, 41 | created: new Date('2019-01-16'), 42 | due: new Date('2019-02-01'), 43 | title: 'Missing bottom border on panel', 44 | description: 'There needs to be a border in the bottom in the panel' 45 | + ' that appears when clicking on Add', 46 | }, 47 | ]; 48 | 49 | db.issues.insertMany(issuesDB); 50 | const count = db.issues.count(); 51 | print('Inserted', count, 'issues'); 52 | 53 | db.counters.remove({ _id: 'issues' }); 54 | db.counters.insert({ _id: 'issues', current: count }); 55 | 56 | db.issues.createIndex({ id: 1 }, { unique: true }); 57 | db.issues.createIndex({ status: 1 }); 58 | db.issues.createIndex({ owner: 1 }); 59 | db.issues.createIndex({ created: 1 }); 60 | db.issues.createIndex({ title: 'text', description: 'text' }); 61 | 62 | db.deleted_issues.createIndex({ id: 1 }, { unique: true }); 63 | -------------------------------------------------------------------------------- /api/scripts/trymongo.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { MongoClient } = require('mongodb'); 3 | 4 | const url = process.env.DB_URL || 'mongodb://localhost/issuetracker'; 5 | 6 | function testWithCallbacks(callback) { 7 | console.log('\n--- testWithCallbacks ---'); 8 | const client = new MongoClient(url, { useNewUrlParser: true }); 9 | client.connect((connErr) => { 10 | if (connErr) { 11 | callback(connErr); 12 | return; 13 | } 14 | console.log('Connected to MongoDB URL', url); 15 | 16 | const db = client.db(); 17 | const collection = db.collection('employees'); 18 | 19 | const employee = { id: 1, name: 'A. Callback', age: 23 }; 20 | collection.insertOne(employee, (insertErr, result) => { 21 | if (insertErr) { 22 | client.close(); 23 | callback(insertErr); 24 | return; 25 | } 26 | console.log('Result of insert:\n', result.insertedId); 27 | collection.find({ _id: result.insertedId }) 28 | .toArray((findErr, docs) => { 29 | if (findErr) { 30 | client.close(); 31 | callback(findErr); 32 | return; 33 | } 34 | console.log('Result of find:\n', docs); 35 | client.close(); 36 | callback(); 37 | }); 38 | }); 39 | }); 40 | } 41 | 42 | async function testWithAsync() { 43 | console.log('\n--- testWithAsync ---'); 44 | const client = new MongoClient(url, { useNewUrlParser: true }); 45 | try { 46 | await client.connect(); 47 | console.log('Connected to MongoDB URL', url); 48 | const db = client.db(); 49 | const collection = db.collection('employees'); 50 | 51 | const employee = { id: 2, name: 'B. Async', age: 16 }; 52 | const result = await collection.insertOne(employee); 53 | console.log('Result of insert:\n', result.insertedId); 54 | 55 | const docs = await collection.find({ _id: result.insertedId }) 56 | .toArray(); 57 | console.log('Result of find:\n', docs); 58 | } catch (err) { 59 | console.log(err); 60 | } finally { 61 | client.close(); 62 | } 63 | } 64 | 65 | testWithCallbacks((err) => { 66 | if (err) { 67 | console.log(err); 68 | } 69 | testWithAsync(); 70 | }); 71 | -------------------------------------------------------------------------------- /ui/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | const webpack = require('webpack'); 4 | 5 | const browserConfig = { 6 | mode: 'development', 7 | entry: { app: ['./browser/App.jsx'] }, 8 | output: { 9 | filename: '[name].bundle.js', 10 | path: path.resolve(__dirname, 'public'), 11 | publicPath: '/', 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.jsx?$/, 17 | exclude: /node_modules/, 18 | use: { 19 | loader: 'babel-loader', 20 | options: { 21 | presets: [ 22 | ['@babel/preset-env', { 23 | targets: { 24 | ie: '11', 25 | edge: '15', 26 | safari: '10', 27 | firefox: '50', 28 | chrome: '49', 29 | }, 30 | }], 31 | '@babel/preset-react', 32 | ], 33 | }, 34 | }, 35 | }, 36 | ], 37 | }, 38 | optimization: { 39 | splitChunks: { 40 | name: 'vendor', 41 | chunks: 'all', 42 | }, 43 | }, 44 | plugins: [ 45 | new webpack.DefinePlugin({ 46 | __isBrowser__: 'true', 47 | }), 48 | ], 49 | devtool: 'source-map', 50 | }; 51 | 52 | const serverConfig = { 53 | mode: 'development', 54 | entry: { server: ['./server/uiserver.js'] }, 55 | target: 'node', 56 | externals: [nodeExternals()], 57 | output: { 58 | filename: 'server.js', 59 | path: path.resolve(__dirname, 'dist'), 60 | publicPath: '/', 61 | }, 62 | module: { 63 | rules: [ 64 | { 65 | test: /\.jsx?$/, 66 | use: { 67 | loader: 'babel-loader', 68 | options: { 69 | presets: [ 70 | ['@babel/preset-env', { 71 | targets: { node: '10' }, 72 | }], 73 | '@babel/preset-react', 74 | ], 75 | }, 76 | }, 77 | }, 78 | ], 79 | }, 80 | plugins: [ 81 | new webpack.DefinePlugin({ 82 | __isBrowser__: 'false', 83 | }), 84 | ], 85 | devtool: 'source-map', 86 | }; 87 | 88 | module.exports = [browserConfig, serverConfig]; 89 | -------------------------------------------------------------------------------- /ui/server/uiserver.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import express from 'express'; 3 | import proxy from 'http-proxy-middleware'; 4 | import SourceMapSupport from 'source-map-support'; 5 | 6 | import render from './render.jsx'; 7 | 8 | const app = express(); 9 | 10 | SourceMapSupport.install(); 11 | dotenv.config(); 12 | const enableHMR = (process.env.ENABLE_HMR || 'true') === 'true'; 13 | 14 | if (enableHMR && (process.env.NODE_ENV !== 'production')) { 15 | console.log('Adding dev middlware, enabling HMR'); 16 | /* eslint "global-require": "off" */ 17 | /* eslint "import/no-extraneous-dependencies": "off" */ 18 | const webpack = require('webpack'); 19 | const devMiddleware = require('webpack-dev-middleware'); 20 | const hotMiddleware = require('webpack-hot-middleware'); 21 | 22 | const config = require('../webpack.config.js')[0]; 23 | config.entry.app.push('webpack-hot-middleware/client'); 24 | config.plugins = config.plugins || []; 25 | config.plugins.push(new webpack.HotModuleReplacementPlugin()); 26 | 27 | const compiler = webpack(config); 28 | app.use(devMiddleware(compiler)); 29 | app.use(hotMiddleware(compiler)); 30 | } 31 | 32 | app.use(express.static('public')); 33 | 34 | const apiProxyTarget = process.env.API_PROXY_TARGET; 35 | if (apiProxyTarget) { 36 | app.use('/graphql', proxy({ target: apiProxyTarget, changeOrigin: true })); 37 | app.use('/auth', proxy({ target: apiProxyTarget, changeOrigin: true })); 38 | } 39 | 40 | if (!process.env.UI_API_ENDPOINT) { 41 | process.env.UI_API_ENDPOINT = 'http://localhost:3000/graphql'; 42 | } 43 | 44 | if (!process.env.UI_SERVER_API_ENDPOINT) { 45 | process.env.UI_SERVER_API_ENDPOINT = process.env.UI_API_ENDPOINT; 46 | } 47 | 48 | if (!process.env.UI_AUTH_ENDPOINT) { 49 | process.env.UI_AUTH_ENDPOINT = 'http://localhost:3000/auth'; 50 | } 51 | 52 | app.get('/env.js', (req, res) => { 53 | const env = { 54 | UI_API_ENDPOINT: process.env.UI_API_ENDPOINT, 55 | UI_AUTH_ENDPOINT: process.env.UI_AUTH_ENDPOINT, 56 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 57 | }; 58 | res.send(`window.ENV = ${JSON.stringify(env)}`); 59 | }); 60 | 61 | app.get('*', (req, res, next) => { 62 | render(req, res, next); 63 | }); 64 | 65 | const port = process.env.PORT || 8000; 66 | 67 | app.listen(port, () => { 68 | console.log(`UI started on port ${port}`); 69 | }); 70 | 71 | if (module.hot) { 72 | module.hot.accept('./render.jsx'); 73 | } 74 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pro-mern-stack-2-ui", 3 | "version": "1.0.0", 4 | "description": "Pro MERN Stack (2nd Edition) - UI", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "10.x", 8 | "npm": "6.x" 9 | }, 10 | "scripts": { 11 | "#start": "UI server. HMR is enabled in dev mode.", 12 | "start": "node dist/server.js", 13 | "#lint": "Runs ESLint on all relevant files", 14 | "lint": "eslint server src browser --ext js,jsx", 15 | "#compile": "Generates JS bundles for production. Use with start.", 16 | "compile": "webpack --mode production", 17 | "#watch-server-hmr": "Recompile server HMR bundle on changes.", 18 | "watch-server-hmr": "webpack -w --config webpack.serverHMR.js", 19 | "#dev-all": "Dev mode: watch for server changes and start UI server", 20 | "dev-all": "rm dist/* && npm run watch-server-hmr & sleep 5 && npm start", 21 | "heroku-postbuild": "npm run compile && ln -sf ../node_modules/bootstrap/dist public/bootstrap" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/vasansr/pro-mern-stack-2.git" 26 | }, 27 | "author": "vasan.promern@gmail.com", 28 | "license": "ISC", 29 | "homepage": "https://github.com/vasansr/pro-mern-stack-2", 30 | "dependencies": { 31 | "babel-polyfill": "^6.26.0", 32 | "bootstrap": "^3.4.0", 33 | "dotenv": "^6.2.0", 34 | "express": "^4.16.4", 35 | "http-proxy-middleware": "^0.19.1", 36 | "isomorphic-fetch": "^2.2.1", 37 | "nodemon": "^1.18.9", 38 | "prop-types": "^15.6.2", 39 | "react": "^16.7.0", 40 | "react-bootstrap": "^0.32.4", 41 | "react-dom": "^16.7.0", 42 | "react-router-bootstrap": "^0.24.4", 43 | "react-router-dom": "^4.3.1", 44 | "react-select": "^2.2.0", 45 | "serialize-javascript": "^1.6.1", 46 | "source-map-support": "^0.5.9", 47 | "url-search-params": "^1.1.0" 48 | }, 49 | "devDependencies": { 50 | "@babel/cli": "^7.2.3", 51 | "@babel/core": "^7.2.2", 52 | "@babel/preset-env": "^7.2.3", 53 | "@babel/preset-react": "^7.0.0", 54 | "babel-loader": "^8.0.5", 55 | "eslint": "^5.12.0", 56 | "eslint-config-airbnb": "^17.1.0", 57 | "eslint-plugin-import": "^2.14.0", 58 | "eslint-plugin-jsx-a11y": "^6.1.2", 59 | "eslint-plugin-react": "^7.12.3", 60 | "webpack": "^4.28.3", 61 | "webpack-cli": "^3.2.1", 62 | "webpack-dev-middleware": "^3.5.0", 63 | "webpack-hot-middleware": "^2.24.3", 64 | "webpack-merge": "^4.2.1", 65 | "webpack-node-externals": "^1.7.2" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /api/auth.js: -------------------------------------------------------------------------------- 1 | const Router = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const { OAuth2Client } = require('google-auth-library'); 4 | const jwt = require('jsonwebtoken'); 5 | const { AuthenticationError } = require('apollo-server-express'); 6 | const cors = require('cors'); 7 | 8 | let { JWT_SECRET } = process.env; 9 | 10 | if (!JWT_SECRET) { 11 | if (process.env.NODE_ENV !== 'production') { 12 | JWT_SECRET = 'tempjwtsecretfordevonly'; 13 | console.log('Missing env var JWT_SECRET. Using unsafe dev secret'); 14 | } else { 15 | console.log('Missing env var JWT_SECRET. Authentication disabled'); 16 | } 17 | } 18 | 19 | const routes = new Router(); 20 | routes.use(bodyParser.json()); 21 | 22 | const origin = process.env.UI_SERVER_ORIGIN || 'http://localhost:8000'; 23 | routes.use(cors({ origin, credentials: true })); 24 | 25 | function getUser(req) { 26 | const token = req.cookies.jwt; 27 | if (!token) return { signedIn: false }; 28 | 29 | try { 30 | const credentials = jwt.verify(token, JWT_SECRET); 31 | return credentials; 32 | } catch (error) { 33 | return { signedIn: false }; 34 | } 35 | } 36 | 37 | routes.post('/signin', async (req, res) => { 38 | if (!JWT_SECRET) { 39 | res.status(500).send('Missing JWT_SECRET. Refusing to authenticate'); 40 | } 41 | 42 | const googleToken = req.body.google_token; 43 | if (!googleToken) { 44 | res.status(400).send({ code: 400, message: 'Missing Token' }); 45 | return; 46 | } 47 | 48 | const client = new OAuth2Client(); 49 | let payload; 50 | try { 51 | const ticket = await client.verifyIdToken({ idToken: googleToken }); 52 | payload = ticket.getPayload(); 53 | } catch (error) { 54 | res.status(403).send('Invalid credentials'); 55 | } 56 | 57 | const { given_name: givenName, name, email } = payload; 58 | const credentials = { 59 | signedIn: true, givenName, name, email, 60 | }; 61 | 62 | const token = jwt.sign(credentials, JWT_SECRET); 63 | res.cookie('jwt', token, { httpOnly: true, domain: process.env.COOKIE_DOMAIN }); 64 | 65 | res.json(credentials); 66 | }); 67 | 68 | routes.post('/signout', async (req, res) => { 69 | res.clearCookie('jwt'); 70 | res.json({ status: 'ok' }); 71 | }); 72 | 73 | routes.post('/user', (req, res) => { 74 | res.json(getUser(req)); 75 | }); 76 | 77 | function mustBeSignedIn(resolver) { 78 | return (root, args, { user }) => { 79 | if (!user || !user.signedIn) { 80 | throw new AuthenticationError('You must be signed in'); 81 | } 82 | return resolver(root, args, { user }); 83 | }; 84 | } 85 | 86 | function resolveUser(_, args, { user }) { 87 | return user; 88 | } 89 | 90 | module.exports = { 91 | routes, getUser, mustBeSignedIn, resolveUser, 92 | }; 93 | -------------------------------------------------------------------------------- /ui/src/IssueAddNavItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import { 4 | NavItem, Glyphicon, Modal, Form, FormGroup, FormControl, ControlLabel, 5 | Button, ButtonToolbar, Tooltip, OverlayTrigger, 6 | } from 'react-bootstrap'; 7 | 8 | import graphQLFetch from './graphQLFetch.js'; 9 | import withToast from './withToast.jsx'; 10 | 11 | class IssueAddNavItem extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | this.state = { 15 | showing: false, 16 | }; 17 | this.showModal = this.showModal.bind(this); 18 | this.hideModal = this.hideModal.bind(this); 19 | this.handleSubmit = this.handleSubmit.bind(this); 20 | } 21 | 22 | showModal() { 23 | this.setState({ showing: true }); 24 | } 25 | 26 | hideModal() { 27 | this.setState({ showing: false }); 28 | } 29 | 30 | async handleSubmit(e) { 31 | e.preventDefault(); 32 | this.hideModal(); 33 | const form = document.forms.issueAdd; 34 | const issue = { 35 | owner: form.owner.value, 36 | title: form.title.value, 37 | due: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 10), 38 | }; 39 | const query = `mutation issueAdd($issue: IssueInputs!) { 40 | issueAdd(issue: $issue) { 41 | id 42 | } 43 | }`; 44 | 45 | const { showError } = this.props; 46 | const data = await graphQLFetch(query, { issue }, showError); 47 | if (data) { 48 | const { history } = this.props; 49 | history.push(`/edit/${data.issueAdd.id}`); 50 | } 51 | } 52 | 53 | render() { 54 | const { showing } = this.state; 55 | const { user: { signedIn } } = this.props; 56 | return ( 57 | 58 | 59 | Create Issue} 63 | > 64 | 65 | 66 | 67 | 68 | 69 | Create Issue 70 | 71 | 72 |
73 | 74 | Title 75 | 76 | 77 | 78 | Owner 79 | 80 | 81 |
82 |
83 | 84 | 85 | 92 | 93 | 94 | 95 |
96 |
97 | ); 98 | } 99 | } 100 | 101 | export default withToast(withRouter(IssueAddNavItem)); 102 | -------------------------------------------------------------------------------- /ui/src/Page.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Navbar, Nav, NavItem, NavDropdown, 4 | MenuItem, Glyphicon, 5 | Grid, Col, 6 | } from 'react-bootstrap'; 7 | import { LinkContainer } from 'react-router-bootstrap'; 8 | 9 | import Contents from './Contents.jsx'; 10 | import IssueAddNavItem from './IssueAddNavItem.jsx'; 11 | import SignInNavItem from './SignInNavItem.jsx'; 12 | import Search from './Search.jsx'; 13 | import UserContext from './UserContext.js'; 14 | import graphQLFetch from './graphQLFetch.js'; 15 | import store from './store.js'; 16 | 17 | function NavBar({ user, onUserChange }) { 18 | return ( 19 | 20 | 21 | Issue Tracker 22 | 23 | 34 | 35 | 36 | 37 | 38 | 39 | 52 | 53 | ); 54 | } 55 | 56 | function Footer() { 57 | return ( 58 | 59 |
60 |

61 | Full source code available at this 62 | {' '} 63 | 64 | GitHub repository 65 | 66 |

67 |
68 | ); 69 | } 70 | 71 | export default class Page extends React.Component { 72 | static async fetchData(cookie) { 73 | const query = `query { user { 74 | signedIn givenName 75 | }}`; 76 | const data = await graphQLFetch(query, null, null, cookie); 77 | return data; 78 | } 79 | 80 | constructor(props) { 81 | super(props); 82 | const user = store.userData ? store.userData.user : null; 83 | delete store.userData; 84 | this.state = { user }; 85 | 86 | this.onUserChange = this.onUserChange.bind(this); 87 | } 88 | 89 | async componentDidMount() { 90 | const { user } = this.state; 91 | if (user == null) { 92 | const data = await Page.fetchData(); 93 | this.setState({ user: data.user }); 94 | } 95 | } 96 | 97 | onUserChange(user) { 98 | this.setState({ user }); 99 | } 100 | 101 | render() { 102 | const { user } = this.state; 103 | if (user == null) return null; 104 | 105 | return ( 106 |
107 | 108 | 109 | 110 | 111 | 112 | 113 |
114 |
115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /ui/src/IssueReport.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Panel, Table } from 'react-bootstrap'; 3 | 4 | import IssueFilter from './IssueFilter.jsx'; 5 | import withToast from './withToast.jsx'; 6 | import graphQLFetch from './graphQLFetch.js'; 7 | import store from './store.js'; 8 | 9 | const statuses = ['New', 'Assigned', 'Fixed', 'Closed']; 10 | 11 | class IssueReport extends React.Component { 12 | static async fetchData(match, search, showError) { 13 | const params = new URLSearchParams(search); 14 | const vars = { }; 15 | if (params.get('status')) vars.status = params.get('status'); 16 | 17 | const effortMin = parseInt(params.get('effortMin'), 10); 18 | if (!Number.isNaN(effortMin)) vars.effortMin = effortMin; 19 | const effortMax = parseInt(params.get('effortMax'), 10); 20 | if (!Number.isNaN(effortMax)) vars.effortMax = effortMax; 21 | 22 | const query = `query issueList( 23 | $status: StatusType 24 | $effortMin: Int 25 | $effortMax: Int 26 | ) { 27 | issueCounts( 28 | status: $status 29 | effortMin: $effortMin 30 | effortMax: $effortMax 31 | ) { 32 | owner New Assigned Fixed Closed 33 | } 34 | }`; 35 | const data = await graphQLFetch(query, vars, showError); 36 | return data; 37 | } 38 | 39 | constructor(props) { 40 | super(props); 41 | const stats = store.initialData ? store.initialData.issueCounts : null; 42 | delete store.initialData; 43 | this.state = { stats }; 44 | } 45 | 46 | componentDidMount() { 47 | const { stats } = this.state; 48 | if (stats == null) this.loadData(); 49 | } 50 | 51 | componentDidUpdate(prevProps) { 52 | const { location: { search: prevSearch } } = prevProps; 53 | const { location: { search } } = this.props; 54 | if (prevSearch !== search) { 55 | this.loadData(); 56 | } 57 | } 58 | 59 | async loadData() { 60 | const { location: { search }, match, showError } = this.props; 61 | const data = await IssueReport.fetchData(match, search, showError); 62 | if (data) { 63 | this.setState({ stats: data.issueCounts }); 64 | } 65 | } 66 | 67 | render() { 68 | const { stats } = this.state; 69 | if (stats == null) return null; 70 | 71 | const headerColumns = ( 72 | statuses.map(status => ( 73 | {status} 74 | )) 75 | ); 76 | 77 | const statRows = stats.map(counts => ( 78 | 79 | {counts.owner} 80 | {statuses.map(status => ( 81 | {counts[status]} 82 | ))} 83 | 84 | )); 85 | 86 | return ( 87 | <> 88 | 89 | 90 | Filter 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 102 | 103 | 104 | {statRows} 105 | 106 |
100 | {headerColumns} 101 |
107 | 108 | ); 109 | } 110 | } 111 | 112 | const IssueReportWithToast = withToast(IssueReport); 113 | IssueReportWithToast.fetchData = IssueReport.fetchData; 114 | 115 | export default IssueReportWithToast; 116 | -------------------------------------------------------------------------------- /ui/src/IssueTable.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import { LinkContainer } from 'react-router-bootstrap'; 4 | import { 5 | Button, Glyphicon, Tooltip, OverlayTrigger, Table, 6 | } from 'react-bootstrap'; 7 | 8 | import UserContext from './UserContext.js'; 9 | 10 | // eslint-disable-next-line react/prefer-stateless-function 11 | class IssueRowPlain extends React.Component { 12 | render() { 13 | const { 14 | issue, location: { search }, closeIssue, deleteIssue, index, 15 | } = this.props; 16 | const user = this.context; 17 | const disabled = !user.signedIn; 18 | 19 | const selectLocation = { pathname: `/issues/${issue.id}`, search }; 20 | const editTooltip = ( 21 | Edit Issue 22 | ); 23 | const closeTooltip = ( 24 | Close Issue 25 | ); 26 | const deleteTooltip = ( 27 | Delete Issue 28 | ); 29 | 30 | function onClose(e) { 31 | e.preventDefault(); 32 | closeIssue(index); 33 | } 34 | 35 | function onDelete(e) { 36 | e.preventDefault(); 37 | deleteIssue(index); 38 | } 39 | 40 | const tableRow = ( 41 | 42 | {issue.id} 43 | {issue.status} 44 | {issue.owner} 45 | {issue.created.toDateString()} 46 | {issue.effort} 47 | {issue.due ? issue.due.toDateString() : ''} 48 | {issue.title} 49 | 50 | 51 | 52 | 55 | 56 | 57 | {' '} 58 | 59 | 62 | 63 | {' '} 64 | 65 | 68 | 69 | 70 | 71 | ); 72 | 73 | return ( 74 | 75 | {tableRow} 76 | 77 | ); 78 | } 79 | } 80 | 81 | IssueRowPlain.contextType = UserContext; 82 | const IssueRow = withRouter(IssueRowPlain); 83 | delete IssueRow.contextType; 84 | 85 | export default function IssueTable({ issues, closeIssue, deleteIssue }) { 86 | const issueRows = issues.map((issue, index) => ( 87 | 94 | )); 95 | 96 | return ( 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | {issueRows} 112 | 113 |
IDStatusOwnerCreatedEffortDue DateTitleAction
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /mongoCommands.md: -------------------------------------------------------------------------------- 1 | # MongoDB shell commands 2 | 3 | This is a list of all the mongo shell commands used in the book. It includes 4 | commands used to try out things or change things manually. 5 | 6 | ## Chapter 6: MongoDB 7 | 8 | ### MongoDB Basics 9 | 10 | ``` 11 | show databases 12 | db 13 | show collections 14 | use issuetracker 15 | show collections 16 | 17 | db.employees.insertOne({ name: { first: 'John', last: 'Doe' }, age: 44 }) 18 | db.employees.find() 19 | db.employees.find().pretty() 20 | 21 | db.employees.insertOne({ name: { first: 'Jane', last: 'Doe' }, age: 54 }) 22 | 23 | let result = db.employees.find().toArray() 24 | result.forEach((e) => print('First Name:', e.name.first)) 25 | result.forEach((e) => printjson(e.name)) 26 | ``` 27 | 28 | ### MongoDB CRUD Operations 29 | ``` 30 | db.employees.insertOne({ 31 | _id: 1, 32 | name: { first: 'John', last: 'Doe' }, 33 | age: 44 34 | }) 35 | 36 | db.employees.insertOne({ 37 | name: {first: 'John', middle: 'H', last: 'Doe'}, 38 | age: 22 39 | }) 40 | 41 | db.employees.drop() 42 | 43 | db.employees.insertOne({ 44 | id: 1, 45 | name: { first: 'John', last: 'Doe' }, 46 | age: 48 47 | }) 48 | 49 | db.employees.insertOne({ 50 | id: 2, 51 | name: { first: 'Jane', last: 'Doe'} , 52 | age: 16 53 | }) 54 | 55 | db.employees.insertMany([ 56 | { id: 3, name: { first: 'Alice', last: 'A' }, age: 32 }, 57 | { id: 4, name: { first: 'Bob', last: 'B' }, age: 64 }, 58 | ]) 59 | 60 | db.employees.findOne({ id: 1 }) 61 | db.employees.find({ age: { $gte: 30 } }) 62 | db.employees.find({ age: { $gte: 30 }, 'name.last': 'Doe' }) 63 | 64 | db.employees.createIndex({ age: 1 }) 65 | db.employees.createIndex({ id: 1 }, { unique: true }) 66 | 67 | db.employees.find({}, { 'name.first': 1, age: 1 }) 68 | db.employees.find({}, { _id: 0, 'name.first': 1, age: 1 }) 69 | 70 | db.employees.updateOne({ id: 2 }, { $set: {age: 23 } }) 71 | 72 | db.employees.updateMany({}, { $set: { organization: 'MyCompany' } }) 73 | 74 | db.employees.replaceOne({ id: 4 }, { 75 | id: 4, 76 | name : { first : "Bobby" }, 77 | age : 66 78 | }); 79 | 80 | db.employees.find({ id: 4 }) 81 | 82 | db.employees.deleteOne({ id: 4 }) 83 | db.employees.count() 84 | 85 | db.employees.aggregate([ 86 | { $group: { _id: null, total_age: { $sum: '$age' } } } 87 | ]) 88 | db.employees.aggregate([ 89 | { $group: { _id: null, count: { $sum: 1 } } } 90 | ]) 91 | 92 | db.employees.insertOne({ 93 | id: 4, 94 | name: { first: 'Bob', last: 'B' }, 95 | age: 64, 96 | organization: 'OtherCompany' 97 | }) 98 | 99 | db.employees.aggregate([ 100 | { $group: { _id: '$organization', total_age: { $sum: '$age' } } } 101 | ]) 102 | 103 | db.employees.aggregate([ 104 | { $group: { _id: '$organization', average_age: { $avg: '$age' } } } 105 | ]) 106 | ``` 107 | 108 | ## Chapter 13: Advanced Features 109 | 110 | ### MongoDB Aggregate 111 | ``` 112 | db.issues.aggregate([ { $match: { status: 'New' } } ]) 113 | 114 | db.issues.aggregate([ 115 | { $group: { 116 | _id: '$owner', 117 | total_effort: { $sum: '$effort' }, 118 | average_effort: {$avg: '$effort' }, 119 | } } 120 | ]) 121 | 122 | db.issues.aggregate([ { $group: { _id: null, count: { $sum: 1 } } }]) 123 | 124 | db.issues.aggregate([ 125 | { $match: { status: 'New' } }, 126 | { $group: { _id: null, count: { $sum: 1 } } }, 127 | ]) 128 | 129 | db.issues.count({ status: 'New' }) 130 | 131 | db.issues.aggregate([ 132 | { $group: { 133 | _id: { owner: '$owner',status: '$status' }, 134 | count: { $sum: 1 }, 135 | } } 136 | ]) 137 | 138 | db.issues.aggregate([ 139 | { $match: { effort: { $gte: 4 } } }, 140 | { $group: { 141 | _id: { owner: '$owner',status: '$status' }, 142 | count: { $sum: 1 }, 143 | } } 144 | ]) 145 | ``` 146 | 147 | ### Text Index API 148 | ``` 149 | db.issues.createIndex({ title: "text" }) 150 | 151 | db.issues.find({ $text: {$search: "click" } }) 152 | 153 | db.issues.getIndexes() 154 | 155 | db.issues.dropIndex('title_text') 156 | db.issues.createIndex({ title: "text", description: "text" }) 157 | 158 | db.issues.find({ $text: {$search: "click" } }) 159 | 160 | db.issues.find({ $text: {$search: "clic" } }) 161 | ``` 162 | -------------------------------------------------------------------------------- /ui/src/SignInNavItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | NavItem, Modal, Button, NavDropdown, MenuItem, 4 | } from 'react-bootstrap'; 5 | 6 | import withToast from './withToast.jsx'; 7 | 8 | class SigninNavItem extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | showing: false, 13 | disabled: true, 14 | }; 15 | this.showModal = this.showModal.bind(this); 16 | this.hideModal = this.hideModal.bind(this); 17 | this.signOut = this.signOut.bind(this); 18 | this.signIn = this.signIn.bind(this); 19 | } 20 | 21 | componentDidMount() { 22 | const clientId = window.ENV.GOOGLE_CLIENT_ID; 23 | if (!clientId) return; 24 | window.gapi.load('auth2', () => { 25 | if (!window.gapi.auth2.getAuthInstance()) { 26 | window.gapi.auth2.init({ client_id: clientId }).then(() => { 27 | this.setState({ disabled: false }); 28 | }); 29 | } 30 | }); 31 | } 32 | 33 | async signIn() { 34 | this.hideModal(); 35 | const { showError } = this.props; 36 | let googleToken; 37 | try { 38 | const auth2 = window.gapi.auth2.getAuthInstance(); 39 | const googleUser = await auth2.signIn(); 40 | googleToken = googleUser.getAuthResponse().id_token; 41 | } catch (error) { 42 | showError(`Error authenticating with Google: ${error.error}`); 43 | } 44 | 45 | try { 46 | const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT; 47 | const response = await fetch(`${apiEndpoint}/signin`, { 48 | method: 'POST', 49 | credentials: 'include', 50 | headers: { 'Content-Type': 'application/json' }, 51 | body: JSON.stringify({ google_token: googleToken }), 52 | }); 53 | const body = await response.text(); 54 | const result = JSON.parse(body); 55 | const { signedIn, givenName } = result; 56 | 57 | const { onUserChange } = this.props; 58 | onUserChange({ signedIn, givenName }); 59 | } catch (error) { 60 | showError(`Error signing into the app: ${error}`); 61 | } 62 | } 63 | 64 | async signOut() { 65 | const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT; 66 | const { showError } = this.props; 67 | try { 68 | await fetch(`${apiEndpoint}/signout`, { 69 | method: 'POST', 70 | credentials: 'include', 71 | }); 72 | const auth2 = window.gapi.auth2.getAuthInstance(); 73 | await auth2.signOut(); 74 | const { onUserChange } = this.props; 75 | onUserChange({ signedIn: false, givenName: '' }); 76 | } catch (error) { 77 | showError(`Error signing out: ${error}`); 78 | } 79 | } 80 | 81 | showModal() { 82 | const clientId = window.ENV.GOOGLE_CLIENT_ID; 83 | const { showError } = this.props; 84 | if (!clientId) { 85 | showError('Missing environment variable GOOGLE_CLIENT_ID'); 86 | return; 87 | } 88 | this.setState({ showing: true }); 89 | } 90 | 91 | hideModal() { 92 | this.setState({ showing: false }); 93 | } 94 | 95 | render() { 96 | const { user } = this.props; 97 | if (user.signedIn) { 98 | return ( 99 | 100 | Sign out 101 | 102 | ); 103 | } 104 | 105 | const { showing, disabled } = this.state; 106 | return ( 107 | <> 108 | 109 | Sign in 110 | 111 | 112 | 113 | Sign in 114 | 115 | 116 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | ); 131 | } 132 | } 133 | 134 | export default withToast(SigninNavItem); 135 | -------------------------------------------------------------------------------- /ui/src/IssueFilter.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import URLSearchParams from 'url-search-params'; 3 | import { withRouter } from 'react-router-dom'; 4 | import { 5 | ButtonToolbar, Button, FormGroup, FormControl, ControlLabel, InputGroup, 6 | Row, Col, 7 | } from 'react-bootstrap'; 8 | 9 | class IssueFilter extends React.Component { 10 | constructor({ location: { search } }) { 11 | super(); 12 | const params = new URLSearchParams(search); 13 | this.state = { 14 | status: params.get('status') || '', 15 | effortMin: params.get('effortMin') || '', 16 | effortMax: params.get('effortMax') || '', 17 | changed: false, 18 | }; 19 | 20 | this.onChangeStatus = this.onChangeStatus.bind(this); 21 | this.onChangeEffortMin = this.onChangeEffortMin.bind(this); 22 | this.onChangeEffortMax = this.onChangeEffortMax.bind(this); 23 | this.applyFilter = this.applyFilter.bind(this); 24 | this.showOriginalFilter = this.showOriginalFilter.bind(this); 25 | } 26 | 27 | componentDidUpdate(prevProps) { 28 | const { location: { search: prevSearch } } = prevProps; 29 | const { location: { search } } = this.props; 30 | if (prevSearch !== search) { 31 | this.showOriginalFilter(); 32 | } 33 | } 34 | 35 | onChangeStatus(e) { 36 | this.setState({ status: e.target.value, changed: true }); 37 | } 38 | 39 | onChangeEffortMin(e) { 40 | const effortString = e.target.value; 41 | if (effortString.match(/^\d*$/)) { 42 | this.setState({ effortMin: e.target.value, changed: true }); 43 | } 44 | } 45 | 46 | onChangeEffortMax(e) { 47 | const effortString = e.target.value; 48 | if (effortString.match(/^\d*$/)) { 49 | this.setState({ effortMax: e.target.value, changed: true }); 50 | } 51 | } 52 | 53 | showOriginalFilter() { 54 | const { location: { search } } = this.props; 55 | const params = new URLSearchParams(search); 56 | this.setState({ 57 | status: params.get('status') || '', 58 | effortMin: params.get('effortMin') || '', 59 | effortMax: params.get('effortMax') || '', 60 | changed: false, 61 | }); 62 | } 63 | 64 | applyFilter() { 65 | const { status, effortMin, effortMax } = this.state; 66 | const { history, urlBase } = this.props; 67 | const params = new URLSearchParams(); 68 | if (status) params.set('status', status); 69 | if (effortMin) params.set('effortMin', effortMin); 70 | if (effortMax) params.set('effortMax', effortMax); 71 | 72 | const search = params.toString() ? `?${params.toString()}` : ''; 73 | history.push({ pathname: urlBase, search }); 74 | } 75 | 76 | render() { 77 | const { status, changed } = this.state; 78 | const { effortMin, effortMax } = this.state; 79 | return ( 80 | 81 | 82 | 83 | Status: 84 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | Effort between: 100 | 101 | 102 | - 103 | 104 | 105 | 106 | 107 | 108 | 109 |   110 | 111 | 114 | 121 | 122 | 123 | 124 | 125 | ); 126 | } 127 | } 128 | 129 | export default withRouter(IssueFilter); 130 | -------------------------------------------------------------------------------- /api/issue.js: -------------------------------------------------------------------------------- 1 | const { UserInputError } = require('apollo-server-express'); 2 | const { getDb, getNextSequence } = require('./db.js'); 3 | const { mustBeSignedIn } = require('./auth.js'); 4 | 5 | async function get(_, { id }) { 6 | const db = getDb(); 7 | const issue = await db.collection('issues').findOne({ id }); 8 | return issue; 9 | } 10 | 11 | const PAGE_SIZE = 10; 12 | 13 | async function list(_, { 14 | status, effortMin, effortMax, search, page, 15 | }) { 16 | const db = getDb(); 17 | const filter = {}; 18 | 19 | if (status) filter.status = status; 20 | 21 | if (effortMin !== undefined || effortMax !== undefined) { 22 | filter.effort = {}; 23 | if (effortMin !== undefined) filter.effort.$gte = effortMin; 24 | if (effortMax !== undefined) filter.effort.$lte = effortMax; 25 | } 26 | 27 | if (search) filter.$text = { $search: search }; 28 | 29 | const cursor = db.collection('issues').find(filter) 30 | .sort({ id: 1 }) 31 | .skip(PAGE_SIZE * (page - 1)) 32 | .limit(PAGE_SIZE); 33 | 34 | const totalCount = await cursor.count(false); 35 | const issues = cursor.toArray(); 36 | const pages = Math.ceil(totalCount / PAGE_SIZE); 37 | return { issues, pages }; 38 | } 39 | 40 | function validate(issue) { 41 | const errors = []; 42 | if (issue.title.length < 3) { 43 | errors.push('Field "title" must be at least 3 characters long.'); 44 | } 45 | if (issue.status === 'Assigned' && !issue.owner) { 46 | errors.push('Field "owner" is required when status is "Assigned"'); 47 | } 48 | if (errors.length > 0) { 49 | throw new UserInputError('Invalid input(s)', { errors }); 50 | } 51 | } 52 | 53 | async function add(_, { issue }) { 54 | const db = getDb(); 55 | validate(issue); 56 | 57 | const newIssue = Object.assign({}, issue); 58 | newIssue.created = new Date(); 59 | newIssue.id = await getNextSequence('issues'); 60 | 61 | const result = await db.collection('issues').insertOne(newIssue); 62 | const savedIssue = await db.collection('issues') 63 | .findOne({ _id: result.insertedId }); 64 | return savedIssue; 65 | } 66 | 67 | async function update(_, { id, changes }) { 68 | const db = getDb(); 69 | if (changes.title || changes.status || changes.owner) { 70 | const issue = await db.collection('issues').findOne({ id }); 71 | Object.assign(issue, changes); 72 | validate(issue); 73 | } 74 | await db.collection('issues').updateOne({ id }, { $set: changes }); 75 | const savedIssue = await db.collection('issues').findOne({ id }); 76 | return savedIssue; 77 | } 78 | 79 | async function remove(_, { id }) { 80 | const db = getDb(); 81 | const issue = await db.collection('issues').findOne({ id }); 82 | if (!issue) return false; 83 | issue.deleted = new Date(); 84 | 85 | let result = await db.collection('deleted_issues').insertOne(issue); 86 | if (result.insertedId) { 87 | result = await db.collection('issues').removeOne({ id }); 88 | return result.deletedCount === 1; 89 | } 90 | return false; 91 | } 92 | 93 | async function restore(_, { id }) { 94 | const db = getDb(); 95 | const issue = await db.collection('deleted_issues').findOne({ id }); 96 | if (!issue) return false; 97 | issue.deleted = new Date(); 98 | 99 | let result = await db.collection('issues').insertOne(issue); 100 | if (result.insertedId) { 101 | result = await db.collection('deleted_issues').removeOne({ id }); 102 | return result.deletedCount === 1; 103 | } 104 | return false; 105 | } 106 | 107 | async function counts(_, { status, effortMin, effortMax }) { 108 | const db = getDb(); 109 | const filter = {}; 110 | 111 | if (status) filter.status = status; 112 | 113 | if (effortMin !== undefined || effortMax !== undefined) { 114 | filter.effort = {}; 115 | if (effortMin !== undefined) filter.effort.$gte = effortMin; 116 | if (effortMax !== undefined) filter.effort.$lte = effortMax; 117 | } 118 | 119 | const results = await db.collection('issues').aggregate([ 120 | { $match: filter }, 121 | { 122 | $group: { 123 | _id: { owner: '$owner', status: '$status' }, 124 | count: { $sum: 1 }, 125 | }, 126 | }, 127 | ]).toArray(); 128 | 129 | const stats = {}; 130 | results.forEach((result) => { 131 | // eslint-disable-next-line no-underscore-dangle 132 | const { owner, status: statusKey } = result._id; 133 | if (!stats[owner]) stats[owner] = { owner }; 134 | stats[owner][statusKey] = result.count; 135 | }); 136 | return Object.values(stats); 137 | } 138 | 139 | module.exports = { 140 | list, 141 | add: mustBeSignedIn(add), 142 | get, 143 | update: mustBeSignedIn(update), 144 | delete: mustBeSignedIn(remove), 145 | restore: mustBeSignedIn(restore), 146 | counts, 147 | }; 148 | -------------------------------------------------------------------------------- /ui/src/IssueList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import URLSearchParams from 'url-search-params'; 3 | import { Panel, Pagination, Button } from 'react-bootstrap'; 4 | import { LinkContainer } from 'react-router-bootstrap'; 5 | 6 | import IssueFilter from './IssueFilter.jsx'; 7 | import IssueTable from './IssueTable.jsx'; 8 | import IssueDetail from './IssueDetail.jsx'; 9 | import graphQLFetch from './graphQLFetch.js'; 10 | import withToast from './withToast.jsx'; 11 | import store from './store.js'; 12 | 13 | const SECTION_SIZE = 5; 14 | 15 | function PageLink({ 16 | params, page, activePage, children, 17 | }) { 18 | params.set('page', page); 19 | if (page === 0) return React.cloneElement(children, { disabled: true }); 20 | return ( 21 | page === activePage} 23 | to={{ search: `?${params.toString()}` }} 24 | > 25 | {children} 26 | 27 | ); 28 | } 29 | 30 | class IssueList extends React.Component { 31 | static async fetchData(match, search, showError) { 32 | const params = new URLSearchParams(search); 33 | const vars = { hasSelection: false, selectedId: 0 }; 34 | if (params.get('status')) vars.status = params.get('status'); 35 | 36 | const effortMin = parseInt(params.get('effortMin'), 10); 37 | if (!Number.isNaN(effortMin)) vars.effortMin = effortMin; 38 | const effortMax = parseInt(params.get('effortMax'), 10); 39 | if (!Number.isNaN(effortMax)) vars.effortMax = effortMax; 40 | 41 | const { params: { id } } = match; 42 | const idInt = parseInt(id, 10); 43 | if (!Number.isNaN(idInt)) { 44 | vars.hasSelection = true; 45 | vars.selectedId = idInt; 46 | } 47 | 48 | let page = parseInt(params.get('page'), 10); 49 | if (Number.isNaN(page)) page = 1; 50 | vars.page = page; 51 | 52 | const query = `query issueList( 53 | $status: StatusType 54 | $effortMin: Int 55 | $effortMax: Int 56 | $hasSelection: Boolean! 57 | $selectedId: Int! 58 | $page: Int 59 | ) { 60 | issueList( 61 | status: $status 62 | effortMin: $effortMin 63 | effortMax: $effortMax 64 | page: $page 65 | ) { 66 | issues { 67 | id title status owner 68 | created effort due 69 | } 70 | pages 71 | } 72 | issue(id: $selectedId) @include (if : $hasSelection) { 73 | id description 74 | } 75 | }`; 76 | 77 | const data = await graphQLFetch(query, vars, showError); 78 | return data; 79 | } 80 | 81 | constructor() { 82 | super(); 83 | const initialData = store.initialData || { issueList: {} }; 84 | const { 85 | issueList: { issues, pages }, issue: selectedIssue, 86 | } = initialData; 87 | delete store.initialData; 88 | this.state = { 89 | issues, 90 | selectedIssue, 91 | pages, 92 | }; 93 | this.closeIssue = this.closeIssue.bind(this); 94 | this.deleteIssue = this.deleteIssue.bind(this); 95 | } 96 | 97 | componentDidMount() { 98 | const { issues } = this.state; 99 | if (issues == null) this.loadData(); 100 | } 101 | 102 | componentDidUpdate(prevProps) { 103 | const { 104 | location: { search: prevSearch }, 105 | match: { params: { id: prevId } }, 106 | } = prevProps; 107 | const { location: { search }, match: { params: { id } } } = this.props; 108 | if (prevSearch !== search || prevId !== id) { 109 | this.loadData(); 110 | } 111 | } 112 | 113 | async loadData() { 114 | const { location: { search }, match, showError } = this.props; 115 | const data = await IssueList.fetchData(match, search, showError); 116 | if (data) { 117 | this.setState({ 118 | issues: data.issueList.issues, 119 | selectedIssue: data.issue, 120 | pages: data.issueList.pages, 121 | }); 122 | } 123 | } 124 | 125 | async closeIssue(index) { 126 | const query = `mutation issueClose($id: Int!) { 127 | issueUpdate(id: $id, changes: { status: Closed }) { 128 | id title status owner 129 | effort created due description 130 | } 131 | }`; 132 | const { issues } = this.state; 133 | const { showError } = this.props; 134 | const data = await graphQLFetch(query, { id: issues[index].id }, 135 | showError); 136 | if (data) { 137 | this.setState((prevState) => { 138 | const newList = [...prevState.issues]; 139 | newList[index] = data.issueUpdate; 140 | return { issues: newList }; 141 | }); 142 | } else { 143 | this.loadData(); 144 | } 145 | } 146 | 147 | async deleteIssue(index) { 148 | const query = `mutation issueDelete($id: Int!) { 149 | issueDelete(id: $id) 150 | }`; 151 | const { issues } = this.state; 152 | const { location: { pathname, search }, history } = this.props; 153 | const { showSuccess, showError } = this.props; 154 | const { id } = issues[index]; 155 | const data = await graphQLFetch(query, { id }, showError); 156 | if (data && data.issueDelete) { 157 | this.setState((prevState) => { 158 | const newList = [...prevState.issues]; 159 | if (pathname === `/issues/${id}`) { 160 | history.push({ pathname: '/issues', search }); 161 | } 162 | newList.splice(index, 1); 163 | return { issues: newList }; 164 | }); 165 | const undoMessage = ( 166 | 167 | {`Deleted issue ${id} successfully.`} 168 | 171 | 172 | ); 173 | showSuccess(undoMessage); 174 | } else { 175 | this.loadData(); 176 | } 177 | } 178 | 179 | async restoreIssue(id) { 180 | const query = `mutation issueRestore($id: Int!) { 181 | issueRestore(id: $id) 182 | }`; 183 | const { showSuccess, showError } = this.props; 184 | const data = await graphQLFetch(query, { id }, showError); 185 | if (data) { 186 | showSuccess(`Issue ${id} restored successfully.`); 187 | this.loadData(); 188 | } 189 | } 190 | 191 | render() { 192 | const { issues } = this.state; 193 | if (issues == null) return null; 194 | 195 | const { selectedIssue, pages } = this.state; 196 | const { location: { search } } = this.props; 197 | 198 | const params = new URLSearchParams(search); 199 | let page = parseInt(params.get('page'), 10); 200 | if (Number.isNaN(page)) page = 1; 201 | const startPage = Math.floor((page - 1) / SECTION_SIZE) * SECTION_SIZE + 1; 202 | const endPage = startPage + SECTION_SIZE - 1; 203 | const prevSection = startPage === 1 ? 0 : startPage - SECTION_SIZE; 204 | const nextSection = endPage >= pages ? 0 : startPage + SECTION_SIZE; 205 | 206 | const items = []; 207 | for (let i = startPage; i <= Math.min(endPage, pages); i += 1) { 208 | params.set('page', i); 209 | items.push(( 210 | 211 | {i} 212 | 213 | )); 214 | } 215 | 216 | return ( 217 | 218 | 219 | 220 | Filter 221 | 222 | 223 | 224 | 225 | 226 | 231 | 232 | 233 | 234 | {'<'} 235 | 236 | {items} 237 | 238 | {'>'} 239 | 240 | 241 | 242 | ); 243 | } 244 | } 245 | 246 | const IssueListWithToast = withToast(IssueList); 247 | IssueListWithToast.fetchData = IssueList.fetchData; 248 | 249 | export default IssueListWithToast; 250 | -------------------------------------------------------------------------------- /commands.md: -------------------------------------------------------------------------------- 1 | # Command-line commands 2 | Hello world 3 | This is a list of all command-line commands used in the book. It includes 4 | installation and other shell-based commands used to try out things or 5 | run scripts manually. 6 | 7 | ## Chapter 2: Hello World 8 | 9 | ### Project Set Up 10 | 11 | ``` 12 | nvm install 10 13 | nvm alias default 10 14 | node --version 15 | npm --version 16 | npm install -g npm@6 17 | npm init 18 | npm install express 19 | npm uninstall express 20 | npm install express@4 21 | ``` 22 | 23 | ### Express 24 | ``` 25 | node server.js 26 | npm start 27 | ``` 28 | 29 | ### JSX Transform 30 | ``` 31 | npm install --save-dev @babel/core@7 @babel/cli@7 32 | node_modules/.bin/babel --version 33 | npx babel --version 34 | npm install --save-dev @babel/preset-react@7 35 | npx babel src --presets @babel/react --out-dir public 36 | ``` 37 | 38 | ### Older Browsers Support 39 | ``` 40 | npm install --no-save @babel/plugin-transform-arrow-functions@7 41 | npx babel src --presets @babel/react --plugins=@babel/plugin-transform-arrow-functions --out-dir public 42 | npm uninstall @babel/plugin-transform-arrow-functions@7 43 | npm install --save-dev @babel/preset-env 44 | npx babel src --out-dir public 45 | ``` 46 | 47 | ### Automate 48 | ``` 49 | npm install nodemon@1 50 | ``` 51 | 52 | ## Chapter 5: Express GraphQL APIs 53 | 54 | ### About API 55 | ``` 56 | npm install graphql@0 apollo-server-express@2 57 | curl "http://localhost:3000/graphql?query=query+\{+about+\}" 58 | ``` 59 | 60 | ## Chapter 6: MongoDB 61 | 62 | ### MongoDB Node.js Driver 63 | ``` 64 | npm install mongodb@3 65 | mongo issuetracker --eval "db.employees.remove({})" 66 | node scripts/trymongo.js 67 | ``` 68 | 69 | ### Schema Initialization 70 | ``` 71 | mongo issuetracker scripts/init.mongo.js 72 | ``` 73 | 74 | ## Chapter 7: Architecture and ESLint 75 | 76 | ### UI Server 77 | ``` 78 | mv server api 79 | mv scripts api 80 | mkdir ui 81 | mv public ui 82 | mv src ui 83 | cd api 84 | npm install 85 | cd .. 86 | cd ui 87 | npm install 88 | cd .. 89 | rm -rf node_modules 90 | ``` 91 | 92 | ### Mulitple Environments 93 | ``` 94 | cd api 95 | npm install dotenv@6 96 | cd .. 97 | cd ui 98 | npm install dotenv@6 99 | ``` 100 | 101 | ### Proxy Based Architecture 102 | ``` 103 | cd ui 104 | npm install http-proxy-middleware@0 105 | ``` 106 | 107 | ### ESLint 108 | ``` 109 | cd api 110 | npm install --save-dev eslint@5 eslint-plugin-import@2 111 | npm install --save-dev eslint-config-airbnb-base@13 112 | npx eslint . 113 | ``` 114 | 115 | ### ESLint for Front-end 116 | ``` 117 | cd ui 118 | npm install --save-dev eslint@5 eslint-plugin-import@2 119 | npm install --save-dev eslint-plugin-jsx-a11y@6 eslint-plugin-react@7 120 | npm install --save-dev eslint-config-airbnb@17 121 | npx eslint . --ignore-pattern public 122 | npx eslint . --ext js,jsx --ignore-pattern public 123 | ``` 124 | 125 | ## Chapter 8: Modularization and Webpack 126 | 127 | ### Front-end Modules and Webpack 128 | ``` 129 | cd ui 130 | npm install --save-dev webpack@4 webpack-cli@3 131 | npx webpack --version 132 | npx webpack public/App.js --output public/app.bundle.js 133 | npx webpack public/App.js --output public/app.bundle.js --mode development 134 | ``` 135 | 136 | ### Transform and Bundle 137 | ``` 138 | cd ui 139 | npm install --save-dev babel-loader@8 140 | npx webpack 141 | npx webpack --watch 142 | ``` 143 | 144 | ### Libraries Bundle 145 | ``` 146 | cd ui 147 | npm install react@16 react-dom@16 148 | npm install prop-types@15 149 | npm install whatwg-fetch@3 150 | npm install babel-polyfill@6 151 | ``` 152 | 153 | ### Hot Module Replacement 154 | ``` 155 | cd ui 156 | npm install --save-dev webpack-dev-middleware@3 157 | npm install --save-dev webpack-hot-middleware@2 158 | ``` 159 | 160 | ## Chapter 9: React Router 161 | 162 | ### Simple Routing 163 | ``` 164 | cd ui 165 | npm install react-router-dom@4 166 | ``` 167 | 168 | ### Query Strings 169 | ``` 170 | cd ui 171 | npm install url-search-params@1 172 | ``` 173 | 174 | ## Chapter 11: React-Bootstrap 175 | 176 | ### Installation 177 | ``` 178 | cd ui 179 | npm install react-bootstrap@0 180 | npm install bootstrap@3 181 | ln -s ../node_modules/bootstrap/dist public/bootstrap 182 | ``` 183 | 184 | ### Navigation Bar 185 | ``` 186 | cd ui 187 | npm install react-router-bootstrap@0 188 | ``` 189 | 190 | ## Chapter 12: Server Rendering 191 | 192 | ### Directory Structure 193 | ``` 194 | cd ui 195 | mkdir browser 196 | mkdir server 197 | mv src/App.jsx browser 198 | mv uiserver.js server 199 | cp src/.babelrc browser 200 | ``` 201 | 202 | ### Basic Server Rendering 203 | ``` 204 | cd ui 205 | npx babel src/About.jsx --out-dir server 206 | ``` 207 | 208 | ### Webpack for UI Server 209 | ``` 210 | cd ui 211 | rm src/.babelrc browser/.babelrc 212 | npm install --save-dev webpack-node-externals@1 213 | mkdir dist 214 | rm server/About.js 215 | mv server/render.js server/render.jsx 216 | npm install source-map-support@0 217 | ``` 218 | 219 | ### HMR for UI Server 220 | ``` 221 | cd ui 222 | npm install --save-dev webpack-merge@4 223 | npx webpack -w --config webpack.serverHMR.js 224 | ``` 225 | 226 | ### Data from APIs 227 | ``` 228 | cd ui 229 | npm uninstall whatwg-fetch 230 | npm install isomorphic-fetch@2 231 | ``` 232 | 233 | ### Routing 234 | ``` 235 | cd ui 236 | rm public/index.html 237 | ``` 238 | 239 | ### Data Fetcher Parameters 240 | ``` 241 | cd ui 242 | npm install serialize-javascript@1 243 | ``` 244 | 245 | 246 | ## Chapter 13: Advanced Features 247 | 248 | ### MongoDB Aggregate 249 | ``` 250 | cd api 251 | mongo issuetracker scripts/generate_data.mongo.js 252 | ``` 253 | 254 | ### Search Bar 255 | ``` 256 | cd ui 257 | npm install react-select@2 258 | ```` 259 | 260 | ## Chapter 14: Authentication 261 | 262 | ### Verify Google Token 263 | ``` 264 | cd api 265 | npm install body-parser@1 266 | npm install google-auth-library@2 267 | ``` 268 | 269 | ### JSON Web Token 270 | ``` 271 | cd api 272 | npm install jsonwebtoken@8 273 | npm install cookie-parser@1 274 | ``` 275 | 276 | ### CORS 277 | ``` 278 | cd api 279 | npm install cors@2 280 | ``` 281 | 282 | ## Chapter 15: Deployment 283 | 284 | ### Git Repositories 285 | 286 | ``` 287 | cd api 288 | git init 289 | git add . 290 | git commit -m "First commit" 291 | git remote add origin git@github.com:$GITHUB_USER/tracker-api.git 292 | git push -u origin master 293 | 294 | cd ui 295 | git init 296 | git add . 297 | git commit -m "First commit" 298 | git remote add origin git@github.com:$GITHUB_USER/tracker-ui.git 299 | git push -u origin master 300 | ``` 301 | 302 | ### MongoDB 303 | ``` 304 | cd api 305 | mongo $DB_URL scripts/init.mongo.js 306 | mongo $DB_URL scripts/generate_data.mongo.js 307 | ``` 308 | 309 | ### Heroku 310 | ``` 311 | heroku login 312 | ``` 313 | 314 | ### API application 315 | ``` 316 | cd api 317 | git commit -am "Changes for Heroku" 318 | git push origin master 319 | heroku create tracker-api-$GITHUB_USER 320 | heroku config:set \ 321 | DB_URL=$DB_URL \ 322 | JWT_SECRET=yourspecialsecret \ 323 | COOKIE_DOMAIN=herokuapp.com 324 | git push heroku master 325 | heroku logs 326 | ``` 327 | 328 | ### UI Application 329 | ``` 330 | cd ui 331 | git commit -am "Changes for Heroku" 332 | git push origin master 333 | heroku create tracker-ui-$GITHUB_USER 334 | heroku config:set \ 335 | UI_API_ENDPOINT=https://tracker-api-$GITHUB_USER.herokuapp.com/graphql \ 336 | UI_AUTH_ENDPOINT=https://tracker-api-$GITHUB_USER.herokuapp.com/auth \ 337 | GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID 338 | git push heroku master 339 | cd api 340 | heroku config:set \ 341 | UI_SERVER_ORIGIN=https://tracker-ui-$GITHUB_USER.herokuapp.com 342 | ``` 343 | 344 | ### Proxy Mode 345 | ``` 346 | cd ui 347 | heroku config:set \ 348 | UI_API_ENDPOINT=https://tracker-ui-$GITHUB_USER.herokuapp.com/graphql \ 349 | UI_AUTH_ENDPOINT=https://tracker-ui-$GITHUB_USER.herokuapp.com/auth \ 350 | UI_SERVER_API_ENDPOINT=https://tracker-api-$GITHUB_USER.herokuapp.com/graphql \ 351 | API_PROXY_TARGET=https://tracker-api-$GITHUB_USER.herokuapp.com 352 | ``` 353 | 354 | ### Non-Proxy Mode 355 | ``` 356 | cd ui 357 | heroku domains:add ui.$CUSTOM_DOMAIN 358 | cd api 359 | heroku domains:add api.$CUSTOM_DOMAIN 360 | cd ui 361 | heroku config:set \ 362 | UI_API_ENDPOINT=http://api.$CUSTOM_DOMAIN/graphql \ 363 | UI_SERVER_API_ENDPOINT=http://api.$CUSTOM_DOMAIN/graphql \ 364 | UI_AUTH_ENDPOINT=http://api.$CUSTOM_DOMAIN/auth 365 | heroku config:unset \ 366 | API_PROXY_TARGET 367 | cd api 368 | heroku config:set \ 369 | UI_SERVER_ORIGIN=http://ui.$CUSTOM_DOMAIN \ 370 | COOKIE_DOMAIN=$CUSTOM_DOMAIN 371 | ``` 372 | -------------------------------------------------------------------------------- /ui/src/IssueEdit.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { LinkContainer } from 'react-router-bootstrap'; 4 | import { 5 | Col, Panel, Form, FormGroup, FormControl, ControlLabel, 6 | ButtonToolbar, Button, Alert, 7 | } from 'react-bootstrap'; 8 | 9 | import graphQLFetch from './graphQLFetch.js'; 10 | import NumInput from './NumInput.jsx'; 11 | import DateInput from './DateInput.jsx'; 12 | import TextInput from './TextInput.jsx'; 13 | import withToast from './withToast.jsx'; 14 | import store from './store.js'; 15 | import UserContext from './UserContext.js'; 16 | 17 | class IssueEdit extends React.Component { 18 | static async fetchData(match, search, showError) { 19 | const query = `query issue($id: Int!) { 20 | issue(id: $id) { 21 | id title status owner 22 | effort created due description 23 | } 24 | }`; 25 | 26 | const { params: { id } } = match; 27 | const result = await graphQLFetch(query, { id }, showError); 28 | return result; 29 | } 30 | 31 | constructor() { 32 | super(); 33 | const issue = store.initialData ? store.initialData.issue : null; 34 | delete store.initialData; 35 | this.state = { 36 | issue, 37 | invalidFields: {}, 38 | showingValidation: false, 39 | }; 40 | this.onChange = this.onChange.bind(this); 41 | this.handleSubmit = this.handleSubmit.bind(this); 42 | this.onValidityChange = this.onValidityChange.bind(this); 43 | this.dismissValidation = this.dismissValidation.bind(this); 44 | this.showValidation = this.showValidation.bind(this); 45 | } 46 | 47 | componentDidMount() { 48 | const { issue } = this.state; 49 | if (issue == null) this.loadData(); 50 | } 51 | 52 | componentDidUpdate(prevProps) { 53 | const { match: { params: { id: prevId } } } = prevProps; 54 | const { match: { params: { id } } } = this.props; 55 | if (id !== prevId) { 56 | this.loadData(); 57 | } 58 | } 59 | 60 | onChange(event, naturalValue) { 61 | const { name, value: textValue } = event.target; 62 | const value = naturalValue === undefined ? textValue : naturalValue; 63 | this.setState(prevState => ({ 64 | issue: { ...prevState.issue, [name]: value }, 65 | })); 66 | } 67 | 68 | onValidityChange(event, valid) { 69 | const { name } = event.target; 70 | this.setState((prevState) => { 71 | const invalidFields = { ...prevState.invalidFields, [name]: !valid }; 72 | if (valid) delete invalidFields[name]; 73 | return { invalidFields }; 74 | }); 75 | } 76 | 77 | async handleSubmit(e) { 78 | e.preventDefault(); 79 | this.showValidation(); 80 | const { issue, invalidFields } = this.state; 81 | if (Object.keys(invalidFields).length !== 0) return; 82 | 83 | const query = `mutation issueUpdate( 84 | $id: Int! 85 | $changes: IssueUpdateInputs! 86 | ) { 87 | issueUpdate( 88 | id: $id 89 | changes: $changes 90 | ) { 91 | id title status owner 92 | effort created due description 93 | } 94 | }`; 95 | 96 | const { id, created, ...changes } = issue; 97 | const { showSuccess, showError } = this.props; 98 | const data = await graphQLFetch(query, { changes, id }, showError); 99 | if (data) { 100 | this.setState({ issue: data.issueUpdate }); 101 | showSuccess('Updated issue successfully'); 102 | } 103 | } 104 | 105 | async loadData() { 106 | const { match, showError } = this.props; 107 | const data = await IssueEdit.fetchData(match, null, showError); 108 | this.setState({ issue: data ? data.issue : {}, invalidFields: {} }); 109 | } 110 | 111 | showValidation() { 112 | this.setState({ showingValidation: true }); 113 | } 114 | 115 | dismissValidation() { 116 | this.setState({ showingValidation: false }); 117 | } 118 | 119 | render() { 120 | const { issue } = this.state; 121 | if (issue == null) return null; 122 | 123 | const { issue: { id } } = this.state; 124 | const { match: { params: { id: propsId } } } = this.props; 125 | if (id == null) { 126 | if (propsId != null) { 127 | return

{`Issue with ID ${propsId} not found.`}

; 128 | } 129 | return null; 130 | } 131 | 132 | const { invalidFields, showingValidation } = this.state; 133 | let validationMessage; 134 | if (Object.keys(invalidFields).length !== 0 && showingValidation) { 135 | validationMessage = ( 136 | 137 | Please correct invalid fields before submitting. 138 | 139 | ); 140 | } 141 | 142 | const { issue: { title, status } } = this.state; 143 | const { issue: { owner, effort, description } } = this.state; 144 | const { issue: { created, due } } = this.state; 145 | 146 | const user = this.context; 147 | 148 | return ( 149 | 150 | 151 | {`Editing issue: ${id}`} 152 | 153 | 154 |
155 | 156 | Created 157 | 158 | 159 | {created.toDateString()} 160 | 161 | 162 | 163 | 164 | Status 165 | 166 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | Owner 181 | 182 | 189 | 190 | 191 | 192 | Effort 193 | 194 | 201 | 202 | 203 | 207 | Due 208 | 209 | 217 | 218 | 219 | 220 | 221 | Title 222 | 223 | 231 | 232 | 233 | 234 | Description 235 | 236 | 246 | 247 | 248 | 249 | 250 | 251 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | {validationMessage} 266 | 267 |
268 |
269 | 270 | Prev 271 | {' | '} 272 | Next 273 | 274 |
275 | ); 276 | } 277 | } 278 | 279 | IssueEdit.contextType = UserContext; 280 | 281 | const IssueEditWithToast = withToast(IssueEdit); 282 | IssueEditWithToast.fetchData = IssueEdit.fetchData; 283 | 284 | export default IssueEditWithToast; 285 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Pro MERN Stack, 2nd Edition](https://media.springernature.com/w306/springer-static/cover-hires/book/978-1-4842-4391-6) 2 | 3 | This is the complete source code compilation of all code listings in the book 4 | Pro MERN Stack, (2nd Edition), published by Apress. The book can be purchased 5 | at any of these websites: 6 | 7 | * [Apress](https://www.apress.com/book/9781484243909) 8 | * [Springer](https://www.springer.com/book/9781484243909) 9 | * [Amazon](https://www.amazon.com/Pro-MERN-Stack-Development-Express/dp/1484243900) 10 | 11 | The 2nd Edition has the following new things as compared to the original, first edition: 12 | 13 | * It uses the latest React (React 16) 14 | * It uses the React Router (React Router 4) 15 | * It uses GraphQL rather than REST based APIs 16 | * It adopts an architecture that lets you run the API and UI on different servers 17 | * It includes deployment instructions on Heroku 18 | * It uses other latest technology such as JWT and CORS. 19 | * ... and many more improvements over the first edition. 20 | 21 | Each section in the book ends with a working application, which corresponds to a branch in this 22 | repository. For each section, you can browse the complete source, or look at the differences from 23 | the previous section/step using the appropriate link against each section, in this page. 24 | 25 | ### Reporting Issues 26 | 27 | There is no discussion forum for this book, so if you do find any problems running the code, 28 | or even in the text of the book, do create [New Issue](https://github.com/vasansr/pro-mern-stack-2/issues/new) in this github project. I or some other reader may help you resolve the issue. 29 | 30 | ### Chapter 1: Introduction 31 | 32 | There are no code listings in this chapter. 33 | 34 | ### Chapter 2: Hello World 35 | * Server-less Hello World: [Full Source](../../tree/02.01-server-less-hello-world) 36 | * JSX: [Full Source](../../tree/02.02-jsx) | [Diffs](../../compare/02.01-server-less-hello-world...02.02-jsx#files_bucket) 37 | * Project Set Up: [Full Source](../../tree/02.03-project-set-up) | [Diffs](../../compare/02.02-jsx...02.03-project-set-up#files_bucket) 38 | * Express: [Full Source](../../tree/02.04-express) | [Diffs](../../compare/02.03-project-set-up...02.04-express#files_bucket) 39 | * Separate Script File: [Full Source](../../tree/02.05-separate-script-file) | [Diffs](../../compare/02.04-express...02.05-separate-script-file#files_bucket) 40 | * JSX Transform: [Full Source](../../tree/02.06-jsx-transform) | [Diffs](../../compare/02.05-separate-script-file...02.06-jsx-transform#files_bucket) 41 | * Older Browser Support: [Full Source](../../tree/02.07-older-browser-support) | [Diffs](../../compare/02.06-jsx-transform...02.07-older-browser-support#files_bucket) 42 | * Automate: [Full Source](../../tree/02.08-automate) | [Diffs](../../compare/02.07-older-browser-support...02.08-automate#files_bucket) 43 | 44 | ### Chapter 3: React Classes 45 | * React Classes: [Full Source](../../tree/03.01-react-classes) | [Diffs](../../compare/02.08-automate...03.01-react-classes#files_bucket) 46 | * Composing Components: [Full Source](../../tree/03.02-composing-components) | [Diffs](../../compare/03.01-react-classes...03.02-composing-components#files_bucket) 47 | * Passing Data Using Properties: [Full Source](../../tree/03.03-passing-data-using-properties) | [Diffs](../../compare/03.02-composing-components...03.03-passing-data-using-properties#files_bucket) 48 | * Passing Data Using Children: [Full Source](../../tree/03.04-passing-data-using-children) | [Diffs](../../compare/03.03-passing-data-using-properties...03.04-passing-data-using-children#files_bucket) 49 | * Dynamic Composition: [Full Source](../../tree/03.05-dynamic-composition) | [Diffs](../../compare/03.04-passing-data-using-children...03.05-dynamic-composition#files_bucket) 50 | 51 | ### Chapter 4: React State 52 | * Initial State: [Full Source](../../tree/04.01-initial-state) | [Diffs](../../compare/03.05-dynamic-composition...04.01-initial-state#files_bucket) 53 | * Async State Initialization: [Full Source](../../tree/04.02-async-state-initialization) | [Diffs](../../compare/04.01-initial-state...04.02-async-state-initialization#files_bucket) 54 | * Updating State: [Full Source](../../tree/04.03-updating-state) | [Diffs](../../compare/04.02-async-state-initialization...04.03-updating-state#files_bucket) 55 | * Lifting State Up: [Full Source](../../tree/04.04-lifting-state-up) | [Diffs](../../compare/04.03-updating-state...04.04-lifting-state-up#files_bucket) 56 | * Event Handling: [Full Source](../../tree/04.05-event-handling) | [Diffs](../../compare/04.04-lifting-state-up...04.05-event-handling#files_bucket) 57 | * Stateless Components: [Full Source](../../tree/04.06-stateless-components) | [Diffs](../../compare/04.05-event-handling...04.06-stateless-components#files_bucket) 58 | 59 | ### Chapter 5: Express and GraphQL 60 | * About API: [Full Source](../../tree/05.01-about-api) | [Diffs](../../compare/04.06-stateless-components...05.01-about-api#files_bucket) 61 | * GraphQL Schema File: [Full Source](../../tree/05.02-graphql-schema-file) | [Diffs](../../compare/05.01-about-api...05.02-graphql-schema-file#files_bucket) 62 | * List API: [Full Source](../../tree/05.03-list-api) | [Diffs](../../compare/05.02-graphql-schema-file...05.03-list-api#files_bucket) 63 | * Integrate List API: [Full Source](../../tree/05.04-integrate-list-api) | [Diffs](../../compare/05.03-list-api...05.04-integrate-list-api#files_bucket) 64 | * Custom Scalar Types: [Full Source](../../tree/05.05-custom-scalar-types) | [Diffs](../../compare/05.04-integrate-list-api...05.05-custom-scalar-types#files_bucket) 65 | * Create API: [Full Source](../../tree/05.06-create-api) | [Diffs](../../compare/05.05-custom-scalar-types...05.06-create-api#files_bucket) 66 | * Integrate Create API: [Full Source](../../tree/05.07-integrate-create-api) | [Diffs](../../compare/05.06-create-api...05.07-integrate-create-api#files_bucket) 67 | * Query Variables: [Full Source](../../tree/05.08-query-variables) | [Diffs](../../compare/05.07-integrate-create-api...05.08-query-variables#files_bucket) 68 | * Input Validations: [Full Source](../../tree/05.09-input-validations) | [Diffs](../../compare/05.08-query-variables...05.09-input-validations#files_bucket) 69 | * Displaying Errors: [Full Source](../../tree/05.10-displaying-errors) | [Diffs](../../compare/05.09-input-validations...05.10-displaying-errors#files_bucket) 70 | 71 | ### Chapter 6: MongoDB 72 | * MongoDB Basics: [Full Source](../../tree/06.01-mongodb-basics) | [Diffs](../../compare/05.10-displaying-errors...06.01-mongodb-basics#files_bucket) 73 | * MongoDB Crud Operations: [Full Source](../../tree/06.02-mongodb-crud-operations) | [Diffs](../../compare/06.01-mongodb-basics...06.02-mongodb-crud-operations#files_bucket) 74 | * MongoDB Node.js Driver: [Full Source](../../tree/06.03-mongodb-node.js-driver) | [Diffs](../../compare/06.02-mongodb-crud-operations...06.03-mongodb-node.js-driver#files_bucket) 75 | * Schema Initialization: [Full Source](../../tree/06.04-schema-initialization) | [Diffs](../../compare/06.03-mongodb-node.js-driver...06.04-schema-initialization#files_bucket) 76 | * Reading From MongoDB: [Full Source](../../tree/06.05-reading-from-mongodb) | [Diffs](../../compare/06.04-schema-initialization...06.05-reading-from-mongodb#files_bucket) 77 | * Writing To MongoDB: [Full Source](../../tree/06.06-writing-to-mongodb) | [Diffs](../../compare/06.05-reading-from-mongodb...06.06-writing-to-mongodb#files_bucket) 78 | 79 | ### Chapter 7: Architecture and ESLint 80 | * UI Server: [Full Source](../../tree/07.01-ui-server) | [Diffs](../../compare/06.06-writing-to-mongodb...07.01-ui-server#files_bucket) 81 | * Multiple Environments: [Full Source](../../tree/07.02-multiple-environments) | [Diffs](../../compare/07.01-ui-server...07.02-multiple-environments#files_bucket) 82 | * Proxy Based Architecture: [Full Source](../../tree/07.03-proxy-based-architecture) | [Diffs](../../compare/07.02-multiple-environments...07.03-proxy-based-architecture#files_bucket) 83 | * ESLint: [Full Source](../../tree/07.04-eslint) | [Diffs](../../compare/07.03-proxy-based-architecture...07.04-eslint#files_bucket) 84 | * ESLint For Front End: [Full Source](../../tree/07.05-eslint-for-front-end) | [Diffs](../../compare/07.04-eslint...07.05-eslint-for-front-end#files_bucket) 85 | * React Proptypes: [Full Source](../../tree/07.06-react-proptypes) | [Diffs](../../compare/07.05-eslint-for-front-end...07.06-react-proptypes#files_bucket) 86 | 87 | ### Chapter 8: Modularization and Webpack 88 | * Backend Modules: [Full Source](../../tree/08.01-backend-modules) | [Diffs](../../compare/07.06-react-proptypes...08.01-backend-modules#files_bucket) 89 | * Front End Modules and Webpack: [Full Source](../../tree/08.02-front-end-modules-and-webpack) | [Diffs](../../compare/08.01-backend-modules...08.02-front-end-modules-and-webpack#files_bucket) 90 | * Transform and Bundle: [Full Source](../../tree/08.03-transform-and-bundle) | [Diffs](../../compare/08.02-front-end-modules-and-webpack...08.03-transform-and-bundle#files_bucket) 91 | * Libraries Bundle: [Full Source](../../tree/08.04-libraries-bundle) | [Diffs](../../compare/08.03-transform-and-bundle...08.04-libraries-bundle#files_bucket) 92 | * Hot Module Replacement: [Full Source](../../tree/08.05-hot-module-replacement) | [Diffs](../../compare/08.04-libraries-bundle...08.05-hot-module-replacement#files_bucket) 93 | * Debugging: [Full Source](../../tree/08.06-debugging) | [Diffs](../../compare/08.05-hot-module-replacement...08.06-debugging#files_bucket) 94 | 95 | ### Chapter 9: React Router 96 | * Simple Routing: [Full Source](../../tree/09.01-simple-routing) | [Diffs](../../compare/08.06-debugging...09.01-simple-routing#files_bucket) 97 | * Route Parameters: [Full Source](../../tree/09.02-route-parameters) | [Diffs](../../compare/09.01-simple-routing...09.02-route-parameters#files_bucket) 98 | * Query Parameters: [Full Source](../../tree/09.03-query-parameters) | [Diffs](../../compare/09.02-route-parameters...09.03-query-parameters#files_bucket) 99 | * Links: [Full Source](../../tree/09.04-links) | [Diffs](../../compare/09.03-query-parameters...09.04-links#files_bucket) 100 | * Programmatic Navigation: [Full Source](../../tree/09.05-programmatic-navigation) | [Diffs](../../compare/09.04-links...09.05-programmatic-navigation#files_bucket) 101 | * Nested Routes: [Full Source](../../tree/09.06-nested-routes) | [Diffs](../../compare/09.05-programmatic-navigation...09.06-nested-routes#files_bucket) 102 | * Browser History Router: [Full Source](../../tree/09.07-browser-history-router) | [Diffs](../../compare/09.06-nested-routes...09.07-browser-history-router#files_bucket) 103 | 104 | ### Chapter 10: Forms 105 | * Controlled Components: [Full Source](../../tree/10.01-controlled-components) | [Diffs](../../compare/09.07-browser-history-router...10.01-controlled-components#files_bucket) 106 | * Controlled Components in Forms: [Full Source](../../tree/10.02-controlled-components-in-forms) | [Diffs](../../compare/10.01-controlled-components...10.02-controlled-components-in-forms#files_bucket) 107 | * More Filters: [Full Source](../../tree/10.03-more-filters) | [Diffs](../../compare/10.02-controlled-components-in-forms...10.03-more-filters#files_bucket) 108 | * Typed Inputs: [Full Source](../../tree/10.04-typed-inputs) | [Diffs](../../compare/10.03-more-filters...10.04-typed-inputs#files_bucket) 109 | * Edit Form: [Full Source](../../tree/10.05-edit-page) | [Diffs](../../compare/10.04-typed-inputs...10.05-edit-page#files_bucket) 110 | * Number Input: [Full Source](../../tree/10.06-number-input) | [Diffs](../../compare/10.05-edit-page...10.06-number-input#files_bucket) 111 | * Date Input: [Full Source](../../tree/10.07-date-input) | [Diffs](../../compare/10.06-number-input...10.07-date-input#files_bucket) 112 | * Text Input: [Full Source](../../tree/10.08-text-input) | [Diffs](../../compare/10.07-date-input...10.08-text-input#files_bucket) 113 | * Update API: [Full Source](../../tree/10.09-update-api) | [Diffs](../../compare/10.08-text-input...10.09-update-api#files_bucket) 114 | * Updating an Issue: [Full Source](../../tree/10.10-updating-an-issue) | [Diffs](../../compare/10.09-update-api...10.10-updating-an-issue#files_bucket) 115 | * Updating a Field: [Full Source](../../tree/10.11-updating-a-field) | [Diffs](../../compare/10.10-updating-an-issue...10.11-updating-a-field#files_bucket) 116 | * Delete API: [Full Source](../../tree/10.12-delete-api) | [Diffs](../../compare/10.11-updating-a-field...10.12-delete-api#files_bucket) 117 | * Deleting an Issue: [Full Source](../../tree/10.13-deleting-an-issue) | [Diffs](../../compare/10.12-delete-api...10.13-deleting-an-issue#files_bucket) 118 | 119 | ### Chapter 11: React Bootstrap 120 | * Bootstrap Installation: [Full Source](../../tree/11.01-bootstrap-installation) | [Diffs](../../compare/10.13-deleting-an-issue...11.01-bootstrap-installation#files_bucket) 121 | * Buttons: [Full Source](../../tree/11.02-buttons) | [Diffs](../../compare/11.01-bootstrap-installation...11.02-buttons#files_bucket) 122 | * Navigation Bar: [Full Source](../../tree/11.03-navigation-bar) | [Diffs](../../compare/11.02-buttons...11.03-navigation-bar#files_bucket) 123 | * Panels: [Full Source](../../tree/11.04-panels) | [Diffs](../../compare/11.03-navigation-bar...11.04-panels#files_bucket) 124 | * Tables: [Full Source](../../tree/11.05-tables) | [Diffs](../../compare/11.04-panels...11.05-tables#files_bucket) 125 | * Forms: [Full Source](../../tree/11.06-forms) | [Diffs](../../compare/11.05-tables...11.06-forms#files_bucket) 126 | * The Grid System: [Full Source](../../tree/11.07-grid-system) | [Diffs](../../compare/11.06-forms...11.07-grid-system#files_bucket) 127 | * Inline Forms: [Full Source](../../tree/11.08-inline-forms) | [Diffs](../../compare/11.07-grid-system...11.08-inline-forms#files_bucket) 128 | * Horizontal Forms: [Full Source](../../tree/11.09-horizontal-forms) | [Diffs](../../compare/11.08-inline-forms...11.09-horizontal-forms#files_bucket) 129 | * Validation Alerts: [Full Source](../../tree/11.10-validation-alerts) | [Diffs](../../compare/11.09-horizontal-forms...11.10-validation-alerts#files_bucket) 130 | * Toasts: [Full Source](../../tree/11.11-toasts) | [Diffs](../../compare/11.10-validation-alerts...11.11-toasts#files_bucket) 131 | * Modals: [Full Source](../../tree/11.12-modals) | [Diffs](../../compare/11.11-toasts...11.12-modals#files_bucket) 132 | 133 | ### Chapter 12: Server Rendering 134 | * New Directory Structure: [Full Source](../../tree/12.01-directory-structure) | [Diffs](../../compare/11.12-modals...12.01-directory-structure#files_bucket) 135 | * Basic Server Rendering: [Full Source](../../tree/12.02-basic-server-rendering) | [Diffs](../../compare/12.01-directory-structure...12.02-basic-server-rendering#files_bucket) 136 | * Webpack for the Server: [Full Source](../../tree/12.03-webpack-for-server) | [Diffs](../../compare/12.02-basic-server-rendering...12.03-webpack-for-server#files_bucket) 137 | * HMR for the Server: [Full Source](../../tree/12.04-hmr-for-server) | [Diffs](../../compare/12.03-webpack-for-server...12.04-hmr-for-server#files_bucket) 138 | * Server Router: [Full Source](../../tree/12.05-server-router) | [Diffs](../../compare/12.04-hmr-for-server...12.05-server-router#files_bucket) 139 | * Hydrate: [Full Source](../../tree/12.06-hydrate) | [Diffs](../../compare/12.05-server-router...12.06-hydrate#files_bucket) 140 | * Data from APIs: [Full Source](../../tree/12.07-data-from-apis) | [Diffs](../../compare/12.06-hydrate...12.07-data-from-apis#files_bucket) 141 | * Syncing Initial Data: [Full Source](../../tree/12.08-syncing-initial-data) | [Diffs](../../compare/12.07-data-from-apis...12.08-syncing-initial-data#files_bucket) 142 | * Common Data Fetcher: [Full Source](../../tree/12.09-common-data-fetcher) | [Diffs](../../compare/12.08-syncing-initial-data...12.09-common-data-fetcher#files_bucket) 143 | * Generated Routes: [Full Source](../../tree/12.10-generated-routes) | [Diffs](../../compare/12.09-common-data-fetcher...12.10-generated-routes#files_bucket) 144 | * Data Fetcher with Parameters: [Full Source](../../tree/12.11-data-fetcher-with-parameters) | [Diffs](../../compare/12.10-generated-routes...12.11-data-fetcher-with-parameters#files_bucket) 145 | * Data Fetcher with Search: [Full Source](../../tree/12.12-data-fetcher-with-search) | [Diffs](../../compare/12.11-data-fetcher-with-parameters...12.12-data-fetcher-with-search#files_bucket) 146 | * Nested Components: [Full Source](../../tree/12.13-nested-components) | [Diffs](../../compare/12.12-data-fetcher-with-search...12.13-nested-components#files_bucket) 147 | * Redirects: [Full Source](../../tree/12.14-redirects) | [Diffs](../../compare/12.13-nested-components...12.14-redirects#files_bucket) 148 | 149 | ### Chapter 13: Advanced Features 150 | * Higher Order Component for Toast: [Full Source](../../tree/13.01-higher-order-component-for-toast) | [Diffs](../../compare/12.14-redirects...13.01-higher-order-component-for-toast#files_bucket) 151 | * MongoDB Aggregate: [Full Source](../../tree/13.02-mongodb-aggregate) | [Diffs](../../compare/13.01-higher-order-component-for-toast...13.02-mongodb-aggregate#files_bucket) 152 | * Issue Counts API: [Full Source](../../tree/13.03-issue-counts-api) | [Diffs](../../compare/13.02-mongodb-aggregate...13.03-issue-counts-api#files_bucket) 153 | * Report Page: [Full Source](../../tree/13.04-report-page) | [Diffs](../../compare/13.03-issue-counts-api...13.04-report-page#files_bucket) 154 | * List API with Pagination: [Full Source](../../tree/13.05-list-api-with-pagination) | [Diffs](../../compare/13.04-report-page...13.05-list-api-with-pagination#files_bucket) 155 | * Pagination UI: [Full Source](../../tree/13.06-pagination-ui) | [Diffs](../../compare/13.05-list-api-with-pagination...13.06-pagination-ui#files_bucket) 156 | * Undo Delete API: [Full Source](../../tree/13.07-undo-delete-api) | [Diffs](../../compare/13.06-pagination-ui...13.07-undo-delete-api#files_bucket) 157 | * Undo Delete UI: [Full Source](../../tree/13.08-undo-delete-ui) | [Diffs](../../compare/13.07-undo-delete-api...13.08-undo-delete-ui#files_bucket) 158 | * Text Index API: [Full Source](../../tree/13.09-text-index-api) | [Diffs](../../compare/13.08-undo-delete-ui...13.09-text-index-api#files_bucket) 159 | * Search Bar: [Full Source](../../tree/13.10-search-bar) | [Diffs](../../compare/13.09-text-index-api...13.10-search-bar#files_bucket) 160 | 161 | ### Chapter 14: Authentication 162 | * Sign-In UI: [Full Source](../../tree/14.01-sign-in-ui) | [Diffs](../../compare/13.10-search-bar...14.01-sign-in-ui#files_bucket) 163 | * Google Sign-In: [Full Source](../../tree/14.02-google-sign-in) | [Diffs](../../compare/14.01-sign-in-ui...14.02-google-sign-in#files_bucket) 164 | * Verifying the Google Token: [Full Source](../../tree/14.03-verifying-google-token) | [Diffs](../../compare/14.02-google-sign-in...14.03-verifying-google-token#files_bucket) 165 | * JSON Web Tokens: [Full Source](../../tree/14.04-json-web-tokens) | [Diffs](../../compare/14.03-verifying-google-token...14.04-json-web-tokens#files_bucket) 166 | * Signing Out: [Full Source](../../tree/14.05-signing-out) | [Diffs](../../compare/14.04-json-web-tokens...14.05-signing-out#files_bucket) 167 | * Authorization: [Full Source](../../tree/14.06-authorization) | [Diffs](../../compare/14.05-signing-out...14.06-authorization#files_bucket) 168 | * Authorization-Aware UI: [Full Source](../../tree/14.07-auth-aware-ui) | [Diffs](../../compare/14.06-authorization...14.07-auth-aware-ui#files_bucket) 169 | * React Context: [Full Source](../../tree/14.08-react-context) | [Diffs](../../compare/14.07-auth-aware-ui...14.08-react-context#files_bucket) 170 | * CORS with Credentials: [Full Source](../../tree/14.09-cors-with-credentials) | [Diffs](../../compare/14.08-react-context...14.09-cors-with-credentials#files_bucket) 171 | * Server Rendering with Credentials: [Full Source](../../tree/14.10-server-rendering-with-credentials) | [Diffs](../../compare/14.09-cors-with-credentials...14.10-server-rendering-with-credentials#files_bucket) 172 | * Cookie Domain: [Full Source](../../tree/14.11-cookie-domain) | [Diffs](../../compare/14.10-server-rendering-with-credentials...14.11-cookie-domain#files_bucket) 173 | 174 | ### Chapter 15: Deployment 175 | * Git Repositories: [Full Source](../../tree/15.01-git-repositories) | [Diffs](../../compare/14.11-cookie-domain...15.01-git-repositories#files_bucket) 176 | * MongoDB: [Full Source](../../tree/15.02-mongodb) | [Diffs](../../compare/15.01-git-repositories...15.02-mongodb#files_bucket) 177 | * Heroku: [Full Source](../../tree/15.03-heroku) | [Diffs](../../compare/15.02-mongodb...15.03-heroku#files_bucket) 178 | * The API Application: [Full Source](../../tree/15.04-api-application) | [Diffs](../../compare/15.03-heroku...15.04-api-application#files_bucket) 179 | * The UI Application: [Full Source](../../tree/15.05-ui-application) | [Diffs](../../compare/15.04-api-application...15.05-ui-application#files_bucket) 180 | * Proxy Mode: [Full Source](../../tree/15.06-proxy-mode) | [Diffs](../../compare/15.05-ui-application...15.06-proxy-mode#files_bucket) 181 | * Non-Proxy Mode: [Full Source](../../tree/15.07-non-proxy-mode) | [Diffs](../../compare/15.06-proxy-mode...15.07-non-proxy-mode#files_bucket) 182 | 183 | ### Chapter 16: Looking Ahead 184 | 185 | There are no code listings in this chapter. 186 | --------------------------------------------------------------------------------