├── 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 |
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 | |
100 | {headerColumns}
101 |
102 |
103 |
104 | {statRows}
105 |
106 |
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 | | ID |
101 | Status |
102 | Owner |
103 | Created |
104 | Effort |
105 | Due Date |
106 | Title |
107 | Action |
108 |
109 |
110 |
111 | {issueRows}
112 |
113 |
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 |
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 |
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 |
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 | 
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 |
--------------------------------------------------------------------------------