initial commit
commit
5ab7727de5
|
@ -0,0 +1,6 @@
|
||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
__mocks__
|
||||||
|
.vscode
|
||||||
|
helm
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directory
|
||||||
|
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Build folder
|
||||||
|
build
|
||||||
|
|
||||||
|
# Webstorm metadata
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Mac files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# json-server db
|
||||||
|
db.json
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"trailingComma": "none",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
# build environment
|
||||||
|
FROM node:12 as builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# production environment
|
||||||
|
FROM node:12
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
|
RUN npm install
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["sh","-c","node serve -s build -p 80"]
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Starter Kit for [Building Applications in React and Redux](http://www.pluralsight.com/author/cory-house) on Pluralsight
|
||||||
|
|
||||||
|
## Get Started
|
||||||
|
|
||||||
|
1. **Install [Node 8](https://nodejs.org)** or newer. Need to run multiple versions of Node? Use [nvm](https://github.com/creationix/nvm) or [nvm-windows](https://github.com/coreybutler/nvm-windows)(https://github.com/coryhouse/pluralsight-redux-starter/archive/master.zip)
|
||||||
|
2. **Navigate to this project's root directory on the command line.**
|
||||||
|
3. **Install Node Packages.** - `npm install`
|
||||||
|
4. **Install [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) and [Redux Dev Tools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en)** in Chrome.
|
||||||
|
5. Having issues? See below.
|
||||||
|
|
||||||
|
## Having Issues? Try these things first:
|
||||||
|
|
||||||
|
1. Run `npm install` - If you forget to do this, you'll get an error when you try to start the app later.
|
||||||
|
2. Don't run the project from a symbolic link. It will cause issues with file watches.
|
||||||
|
3. Delete any .eslintrc in your user directory and disable any ESLint plugin / custom rules within your editor since these will conflict with the ESLint rules defined in the course.
|
||||||
|
4. On Windows? Open your console as an administrator. This will assure the console has the necessary rights to perform installs.
|
||||||
|
5. Ensure you do not have NODE_ENV=production in your env variables as it will not install the devDependencies. To check run this on the command line: `set NODE_ENV`. If it comes back as production, you need to clear this env variable.
|
||||||
|
6. Nothing above work? Delete your node_modules folder and re-run npm install.
|
||||||
|
|
||||||
|
### Production Dependencies
|
||||||
|
|
||||||
|
| **Dependency** | **Use** |
|
||||||
|
| ---------------- | ---------------------------------------------------- |
|
||||||
|
| bootstrap | CSS Framework |
|
||||||
|
| immer | Helper for working with immutable data |
|
||||||
|
| prop-types | Declare types for props passed into React components |
|
||||||
|
| react | React library |
|
||||||
|
| react-dom | React library for DOM rendering |
|
||||||
|
| react-redux | Connects React components to Redux |
|
||||||
|
| react-router-dom | React library for routing |
|
||||||
|
| react-toastify | Display messages to the user |
|
||||||
|
| redux | Library for unidirectional data flows |
|
||||||
|
| redux-thunk | Async redux library |
|
||||||
|
| reselect | Memoize selectors for performance |
|
||||||
|
|
||||||
|
### Development Dependencies
|
||||||
|
|
||||||
|
| **Dependency** | **Use** |
|
||||||
|
| ------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| @babel/core | Transpiles modern JavaScript so it runs cross-browser |
|
||||||
|
| babel-eslint | Lint modern JavaScript via ESLint |
|
||||||
|
| babel-loader | Add Babel support to Webpack |
|
||||||
|
| babel-preset-react-app | Babel preset for working in React. Used by create-react-app too. |
|
||||||
|
| css-loader | Read CSS files via Webpack |
|
||||||
|
| cssnano | Minify CSS |
|
||||||
|
| enzyme | Simplified JavaScript Testing utilities for React |
|
||||||
|
| enzyme-adapter-react-16 | Configure Enzyme to work with React 16 |
|
||||||
|
| eslint | Lints JavaScript |
|
||||||
|
| eslint-loader | Run ESLint via Webpack |
|
||||||
|
| eslint-plugin-import | Advanced linting of ES6 imports |
|
||||||
|
| eslint-plugin-react | Adds additional React-related rules to ESLint |
|
||||||
|
| fetch-mock | Mock fetch calls |
|
||||||
|
| html-webpack-plugin | Generate HTML file via webpack |
|
||||||
|
| http-server | Lightweight HTTP server to serve the production build locally |
|
||||||
|
| jest | Automated testing framework |
|
||||||
|
| json-server | Quickly create mock API that simulates create, update, delete |
|
||||||
|
| mini-css-extract-plugin | Extract imported CSS to a separate file via Webpack |
|
||||||
|
| node-fetch | Make HTTP calls via fetch using Node - Used by fetch-mock |
|
||||||
|
| npm-run-all | Display results of multiple commands on single command line |
|
||||||
|
| postcss-loader | Post-process CSS via Webpack |
|
||||||
|
| react-test-renderer | Render React components for testing |
|
||||||
|
| react-testing-library | Test React components |
|
||||||
|
| redux-immutable-state-invariant | Warn when Redux state is mutated |
|
||||||
|
| redux-mock-store | Mock Redux store for testing |
|
||||||
|
| rimraf | Delete files and folders |
|
||||||
|
| style-loader | Insert imported CSS into app via Webpack |
|
||||||
|
| webpack | Bundler with plugin ecosystem and integrated dev server |
|
||||||
|
| webpack-bundle-analyzer | Generate report of what's in the app's production bundle |
|
||||||
|
| webpack-cli | Run Webpack via the command line |
|
||||||
|
| webpack-dev-server | Serve app via Webpack |
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,116 @@
|
||||||
|
{
|
||||||
|
"name": "react-redux-course",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "React and Redux Pluralsight course by Cory House",
|
||||||
|
"scripts": {
|
||||||
|
"start": "run-p start:dev start:api",
|
||||||
|
"start:dev": "webpack-dev-server --config webpack.config.dev.js --port 3000",
|
||||||
|
"prestart:api": "node tools/createMockDb.js",
|
||||||
|
"start:api": "node tools/apiServer.js",
|
||||||
|
"test": "jest --watch",
|
||||||
|
"test:ci": "jest",
|
||||||
|
"clean:build": "rimraf ./build && mkdir build",
|
||||||
|
"prebuild": "run-p clean:build test:ci",
|
||||||
|
"build": "webpack --config webpack.config.prod.js",
|
||||||
|
"postbuild": "run-p start:api serve:build",
|
||||||
|
"serve:build": "http-server ./build"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"setupFiles": [
|
||||||
|
"./tools/testSetup.js"
|
||||||
|
],
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/tools/fileMock.js",
|
||||||
|
"\\.(css|less)$": "<rootDir>/tools/styleMock.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bootstrap": "4.3.1",
|
||||||
|
"immer": "2.1.3",
|
||||||
|
"prop-types": "15.7.2",
|
||||||
|
"react": "16.8.4",
|
||||||
|
"react-dom": "16.8.4",
|
||||||
|
"react-redux": "6.0.1",
|
||||||
|
"react-router-dom": "5.0.0",
|
||||||
|
"react-toastify": "4.5.2",
|
||||||
|
"redux": "4.0.1",
|
||||||
|
"redux-thunk": "2.3.0",
|
||||||
|
"reselect": "4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "7.3.4",
|
||||||
|
"babel-eslint": "10.0.1",
|
||||||
|
"babel-loader": "8.0.5",
|
||||||
|
"babel-preset-react-app": "7.0.2",
|
||||||
|
"css-loader": "2.1.1",
|
||||||
|
"cssnano": "4.1.10",
|
||||||
|
"enzyme": "3.9.0",
|
||||||
|
"enzyme-adapter-react-16": "1.11.2",
|
||||||
|
"eslint": "5.15.2",
|
||||||
|
"eslint-loader": "2.1.2",
|
||||||
|
"eslint-plugin-import": "2.16.0",
|
||||||
|
"eslint-plugin-react": "7.12.4",
|
||||||
|
"fetch-mock": "7.3.1",
|
||||||
|
"html-webpack-plugin": "3.2.0",
|
||||||
|
"http-server": "0.9.0",
|
||||||
|
"jest": "24.5.0",
|
||||||
|
"json-server": "0.14.2",
|
||||||
|
"mini-css-extract-plugin": "0.5.0",
|
||||||
|
"node-fetch": "^2.3.0",
|
||||||
|
"npm-run-all": "4.1.5",
|
||||||
|
"postcss-loader": "3.0.0",
|
||||||
|
"react-test-renderer": "16.8.4",
|
||||||
|
"react-testing-library": "6.0.0",
|
||||||
|
"redux-immutable-state-invariant": "2.1.0",
|
||||||
|
"redux-mock-store": "1.5.3",
|
||||||
|
"rimraf": "2.6.3",
|
||||||
|
"style-loader": "0.23.1",
|
||||||
|
"webpack": "4.29.6",
|
||||||
|
"webpack-bundle-analyzer": "3.1.0",
|
||||||
|
"webpack-cli": "3.3.0",
|
||||||
|
"webpack-dev-server": "3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"babel": {
|
||||||
|
"presets": [
|
||||||
|
"babel-preset-react-app"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:import/errors",
|
||||||
|
"plugin:import/warnings"
|
||||||
|
],
|
||||||
|
"parser": "babel-eslint",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2018,
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"node": true,
|
||||||
|
"es6": true,
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-debugger": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"react/prop-types": "warn"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
export async function handleResponse(response) {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
if (response.status === 400) {
|
||||||
|
// So, a server-side validation error occurred.
|
||||||
|
// Server side validation returns a string error message, so parse as text instead of json.
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
throw new Error("Network response was not ok.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real app, would likely call an error logging service.
|
||||||
|
export function handleError(error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("API call failed. " + error);
|
||||||
|
throw error;
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { handleResponse, handleError } from "./apiUtils";
|
||||||
|
const baseUrl = process.env.API_URL + "/authors/";
|
||||||
|
|
||||||
|
export function getAuthors() {
|
||||||
|
return fetch(baseUrl).then(handleResponse).catch(handleError);
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { handleResponse, handleError } from "./apiUtils";
|
||||||
|
const baseUrl = process.env.API_URL + "/courses/";
|
||||||
|
|
||||||
|
export function getCourses() {
|
||||||
|
return fetch(baseUrl).then(handleResponse).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCourse(course) {
|
||||||
|
return fetch(baseUrl + (course.id || ""), {
|
||||||
|
method: course.id ? "PUT" : "POST", // POST for create, PUT to update when id already exists.
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(course)
|
||||||
|
})
|
||||||
|
.then(handleResponse)
|
||||||
|
.catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCourse(courseId) {
|
||||||
|
return fetch(baseUrl + courseId, { method: "DELETE" })
|
||||||
|
.then(handleResponse)
|
||||||
|
.catch(handleError);
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Route, Switch } from "react-router-dom";
|
||||||
|
import HomePage from "./home/HomePage";
|
||||||
|
import AboutPage from "./about/AboutPage";
|
||||||
|
import Header from "./common/Header";
|
||||||
|
import PageNotFound from "./PageNotFound";
|
||||||
|
import CoursesPage from "./courses/CoursesPage";
|
||||||
|
import ManageCoursePage from "./courses/ManageCoursePage"; // eslint-disable-line import/no-named-as-default
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="container-fluid">
|
||||||
|
<Header />
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/" component={HomePage} />
|
||||||
|
<Route path="/about" component={AboutPage} />
|
||||||
|
<Route path="/courses" component={CoursesPage} />
|
||||||
|
<Route path="/course/:slug" component={ManageCoursePage} />
|
||||||
|
<Route path="/course" component={ManageCoursePage} />
|
||||||
|
<Route component={PageNotFound} />
|
||||||
|
</Switch>
|
||||||
|
<ToastContainer autoClose={3000} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
|
@ -0,0 +1,5 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const PageNotFound = () => <h1>Oops! Page not found</h1>;
|
||||||
|
|
||||||
|
export default PageNotFound;
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const AboutPage = () => (
|
||||||
|
<div>
|
||||||
|
<h2>About</h2>
|
||||||
|
<p>
|
||||||
|
This app uses React, Redux, React Router, and many other helpful
|
||||||
|
libraries.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AboutPage;
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const activeStyle = { color: "#F15B2A" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<NavLink to="/" activeStyle={activeStyle} exact>
|
||||||
|
Home
|
||||||
|
</NavLink>
|
||||||
|
{" | "}
|
||||||
|
<NavLink to="/courses" activeStyle={activeStyle}>
|
||||||
|
Courses
|
||||||
|
</NavLink>
|
||||||
|
{" | "}
|
||||||
|
<NavLink to="/about" activeStyle={activeStyle}>
|
||||||
|
About
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React from "react";
|
||||||
|
import Header from "./Header";
|
||||||
|
import { mount, shallow } from "enzyme";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
// Note how with shallow render you search for the React component tag
|
||||||
|
it("contains 3 NavLinks via shallow", () => {
|
||||||
|
const numLinks = shallow(<Header />).find("NavLink").length;
|
||||||
|
expect(numLinks).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note how with mount you search for the final rendered HTML since it generates the final DOM.
|
||||||
|
// We also need to pull in React Router's memoryRouter for testing since the Header expects to have React Router's props passed in.
|
||||||
|
it("contains 3 anchors via mount", () => {
|
||||||
|
const numAnchors = mount(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Header />
|
||||||
|
</MemoryRouter>
|
||||||
|
).find("a").length;
|
||||||
|
|
||||||
|
expect(numAnchors).toEqual(3);
|
||||||
|
});
|
|
@ -0,0 +1,49 @@
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
const SelectInput = ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
defaultOption,
|
||||||
|
value,
|
||||||
|
error,
|
||||||
|
options
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor={name}>{label}</label>
|
||||||
|
<div className="field">
|
||||||
|
{/* Note, value is set here rather than on the option - docs: https://facebook.github.io/react/docs/forms.html */}
|
||||||
|
<select
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="form-control"
|
||||||
|
>
|
||||||
|
<option value="">{defaultOption}</option>
|
||||||
|
{options.map((option) => {
|
||||||
|
return (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.text}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SelectInput.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
defaultOption: PropTypes.string,
|
||||||
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
error: PropTypes.string,
|
||||||
|
options: PropTypes.arrayOf(PropTypes.object)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectInput;
|
|
@ -0,0 +1,42 @@
|
||||||
|
/* via https://projects.lukehaas.me/css-loaders/ */
|
||||||
|
.loader,
|
||||||
|
.loader:after {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 10em;
|
||||||
|
height: 10em;
|
||||||
|
}
|
||||||
|
.loader {
|
||||||
|
margin: 60px auto;
|
||||||
|
font-size: 10px;
|
||||||
|
position: relative;
|
||||||
|
text-indent: -9999em;
|
||||||
|
border-top: 1.1em solid rgba(255, 151, 0, 0.2);
|
||||||
|
border-right: 1.1em solid rgba(255, 151, 0, 0.2);
|
||||||
|
border-bottom: 1.1em solid rgba(255, 151, 0, 0.2);
|
||||||
|
border-left: 1.1em solid #ff9700;
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
-ms-transform: translateZ(0);
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-animation: load8 1.1s infinite linear;
|
||||||
|
animation: load8 1.1s infinite linear;
|
||||||
|
}
|
||||||
|
@-webkit-keyframes load8 {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes load8 {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React from "react";
|
||||||
|
import "./Spinner.css";
|
||||||
|
|
||||||
|
const Spinner = () => {
|
||||||
|
return <div className="loader">Loading...</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Spinner;
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
const TextInput = ({ name, label, onChange, placeholder, value, error }) => {
|
||||||
|
let wrapperClass = "form-group";
|
||||||
|
if (error && error.length > 0) {
|
||||||
|
wrapperClass += " " + "has-error";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
<label htmlFor={name}>{label}</label>
|
||||||
|
<div className="field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
className="form-control"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TextInput.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
value: PropTypes.string,
|
||||||
|
error: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextInput;
|
|
@ -0,0 +1,34 @@
|
||||||
|
import React from "react";
|
||||||
|
import CourseForm from "./CourseForm";
|
||||||
|
import { shallow } from "enzyme";
|
||||||
|
|
||||||
|
function renderCourseForm(args) {
|
||||||
|
const defaultProps = {
|
||||||
|
authors: [],
|
||||||
|
course: {},
|
||||||
|
saving: false,
|
||||||
|
errors: {},
|
||||||
|
onSave: () => {},
|
||||||
|
onChange: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = { ...defaultProps, ...args };
|
||||||
|
return shallow(<CourseForm {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders form and header", () => {
|
||||||
|
const wrapper = renderCourseForm();
|
||||||
|
// console.log(wrapper.debug());
|
||||||
|
expect(wrapper.find("form").length).toBe(1);
|
||||||
|
expect(wrapper.find("h2").text()).toEqual("Add Course");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels save buttons as "Save" when not saving', () => {
|
||||||
|
const wrapper = renderCourseForm();
|
||||||
|
expect(wrapper.find("button").text()).toBe("Save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels save button as "Saving..." when saving', () => {
|
||||||
|
const wrapper = renderCourseForm({ saving: true });
|
||||||
|
expect(wrapper.find("button").text()).toBe("Saving...");
|
||||||
|
});
|
|
@ -0,0 +1,35 @@
|
||||||
|
import React from "react";
|
||||||
|
import { cleanup, render } from "react-testing-library";
|
||||||
|
import CourseForm from "./CourseForm";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderCourseForm(args) {
|
||||||
|
let defaultProps = {
|
||||||
|
authors: [],
|
||||||
|
course: {},
|
||||||
|
saving: false,
|
||||||
|
errors: {},
|
||||||
|
onSave: () => {},
|
||||||
|
onChange: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = { ...defaultProps, ...args };
|
||||||
|
return render(<CourseForm {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should render Add Course header", () => {
|
||||||
|
const { getByText } = renderCourseForm();
|
||||||
|
getByText("Add Course");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should label save button as "Save" when not saving', () => {
|
||||||
|
const { getByText } = renderCourseForm();
|
||||||
|
getByText("Save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should label save button as "Saving..." when saving', () => {
|
||||||
|
const { getByText, debug } = renderCourseForm({ saving: true });
|
||||||
|
// debug();
|
||||||
|
getByText("Saving...");
|
||||||
|
});
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React from "react";
|
||||||
|
import CourseForm from "./CourseForm";
|
||||||
|
import renderer from "react-test-renderer";
|
||||||
|
import { courses, authors } from "../../../tools/mockData";
|
||||||
|
|
||||||
|
it("sets submit button label 'Saving...' when saving is true", () => {
|
||||||
|
const tree = renderer.create(
|
||||||
|
<CourseForm
|
||||||
|
course={courses[0]}
|
||||||
|
authors={authors}
|
||||||
|
onSave={jest.fn()}
|
||||||
|
onChange={jest.fn()}
|
||||||
|
saving
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets submit button label 'Save' when saving is false", () => {
|
||||||
|
const tree = renderer.create(
|
||||||
|
<CourseForm
|
||||||
|
course={courses[0]}
|
||||||
|
authors={authors}
|
||||||
|
onSave={jest.fn()}
|
||||||
|
onChange={jest.fn()}
|
||||||
|
saving={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
|
@ -0,0 +1,67 @@
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import TextInput from "../common/TextInput";
|
||||||
|
import SelectInput from "../common/SelectInput";
|
||||||
|
|
||||||
|
const CourseForm = ({
|
||||||
|
course,
|
||||||
|
authors,
|
||||||
|
onSave,
|
||||||
|
onChange,
|
||||||
|
saving = false,
|
||||||
|
errors = {}
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSave}>
|
||||||
|
<h2>{course.id ? "Edit" : "Add"} Course</h2>
|
||||||
|
{errors.onSave && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{errors.onSave}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TextInput
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
value={course.title}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors.title}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
name="authorId"
|
||||||
|
label="Author"
|
||||||
|
value={course.authorId || ""}
|
||||||
|
defaultOption="Select Author"
|
||||||
|
options={authors.map((author) => ({
|
||||||
|
value: author.id,
|
||||||
|
text: author.name
|
||||||
|
}))}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors.author}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
name="category"
|
||||||
|
label="Category"
|
||||||
|
value={course.category}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors.category}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" disabled={saving} className="btn btn-primary">
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CourseForm.propTypes = {
|
||||||
|
authors: PropTypes.array.isRequired,
|
||||||
|
course: PropTypes.object.isRequired,
|
||||||
|
errors: PropTypes.object,
|
||||||
|
onSave: PropTypes.func.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
saving: PropTypes.bool
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseForm;
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const CourseList = ({ courses, onDeleteClick }) => (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{courses.map((course) => {
|
||||||
|
return (
|
||||||
|
<tr key={course.id}>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
className="btn btn-light"
|
||||||
|
href={"http://pluralsight.com/courses/" + course.slug}
|
||||||
|
>
|
||||||
|
Watch
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link to={"/course/" + course.slug}>{course.title}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{course.authorName}</td>
|
||||||
|
<td>{course.category}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
onClick={() => onDeleteClick(course)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
CourseList.propTypes = {
|
||||||
|
courses: PropTypes.array.isRequired,
|
||||||
|
onDeleteClick: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseList;
|
|
@ -0,0 +1,107 @@
|
||||||
|
import React from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import * as courseActions from "../../redux/actions/courseActions";
|
||||||
|
import * as authorActions from "../../redux/actions/authorActions";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { bindActionCreators } from "redux";
|
||||||
|
import CourseList from "./CourseList";
|
||||||
|
import { Redirect } from "react-router-dom";
|
||||||
|
import Spinner from "../common/Spinner";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
class CoursesPage extends React.Component {
|
||||||
|
state = {
|
||||||
|
redirectToAddCoursePage: false
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { courses, authors, actions } = this.props;
|
||||||
|
|
||||||
|
if (courses.length === 0) {
|
||||||
|
actions.loadCourses().catch((error) => {
|
||||||
|
alert("Loading courses failed. " + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authors.length === 0) {
|
||||||
|
actions.loadAuthors().catch((error) => {
|
||||||
|
alert("Loading authors failed. " + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleteCourse = async (course) => {
|
||||||
|
toast.success("Course deleted");
|
||||||
|
try {
|
||||||
|
await this.props.actions.deleteCourse(course);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Delete failed. " + error.message, { autoClose: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{this.state.redirectToAddCoursePage && <Redirect to="/course" />}
|
||||||
|
<h2>Courses</h2>
|
||||||
|
{this.props.loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
style={{ marginBottom: 20 }}
|
||||||
|
className="btn btn-primary add-course"
|
||||||
|
onClick={() => this.setState({ redirectToAddCoursePage: true })}
|
||||||
|
>
|
||||||
|
Add Course
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CourseList
|
||||||
|
courses={this.props.courses}
|
||||||
|
onDeleteClick={this.handleDeleteCourse}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CoursesPage.propTypes = {
|
||||||
|
courses: PropTypes.array.isRequired,
|
||||||
|
authors: PropTypes.array.isRequired,
|
||||||
|
actions: PropTypes.object.isRequired,
|
||||||
|
loading: PropTypes.bool.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapStateToProps(state) {
|
||||||
|
return {
|
||||||
|
courses:
|
||||||
|
state.authors.length === 0
|
||||||
|
? []
|
||||||
|
: state.courses.map((course) => {
|
||||||
|
const author = state.authors.find((a) => a.id === course.authorId);
|
||||||
|
return {
|
||||||
|
...course,
|
||||||
|
authorName: author.name
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
authors: state.authors,
|
||||||
|
loading: state.apiCallsInProgress > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch) {
|
||||||
|
return {
|
||||||
|
actions: bindActionCreators(
|
||||||
|
{
|
||||||
|
loadCourses: courseActions.loadCourses,
|
||||||
|
loadAuthors: authorActions.loadAuthors,
|
||||||
|
deleteCourse: courseActions.deleteCourse
|
||||||
|
},
|
||||||
|
dispatch
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(CoursesPage);
|
|
@ -0,0 +1,128 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { loadCourses, saveCourse } from "../../redux/actions/courseActions";
|
||||||
|
import { loadAuthors } from "../../redux/actions/authorActions";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { bindActionCreators } from "redux";
|
||||||
|
import CourseForm from "./CourseForm";
|
||||||
|
import { newCourse } from "../../../tools/mockData";
|
||||||
|
import Spinner from "../common/Spinner";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export function ManageCoursePage({
|
||||||
|
courses,
|
||||||
|
authors,
|
||||||
|
actions,
|
||||||
|
history,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const [course, setCourse] = useState({ ...props.course });
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (courses.length === 0) {
|
||||||
|
actions.loadCourses().catch((error) => {
|
||||||
|
alert("Loading courses failed. " + error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCourse({ ...props.course });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authors.length === 0) {
|
||||||
|
actions.loadAuthors().catch((error) => {
|
||||||
|
alert("Loading authors failed. " + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [props.course]);
|
||||||
|
|
||||||
|
function handleChange(event) {
|
||||||
|
const { name, value } = event.target;
|
||||||
|
setCourse((prevCourse) => ({
|
||||||
|
...prevCourse,
|
||||||
|
[name]: name === "authorId" ? parseInt(value, 10) : value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formIsValid() {
|
||||||
|
const { title, authorId, category } = course;
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
if (!title) errors.title = "Title is required.";
|
||||||
|
if (!authorId) errors.author = "Author is required";
|
||||||
|
if (!category) errors.category = "Category is required";
|
||||||
|
|
||||||
|
setErrors(errors);
|
||||||
|
// Form is valid if the errors object still has no properties
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!formIsValid()) return;
|
||||||
|
setSaving(true);
|
||||||
|
actions
|
||||||
|
.saveCourse(course)
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Course saved.");
|
||||||
|
history.push("/courses");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setSaving(false);
|
||||||
|
setErrors({ onSave: error.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return authors.length === 0 || course.length === 0 ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<CourseForm
|
||||||
|
course={course}
|
||||||
|
errors={errors}
|
||||||
|
authors={authors}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSave={handleSave}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ManageCoursePage.propTypes = {
|
||||||
|
course: PropTypes.object.isRequired,
|
||||||
|
courses: PropTypes.array.isRequired,
|
||||||
|
authors: PropTypes.array.isRequired,
|
||||||
|
actions: PropTypes.object.isRequired,
|
||||||
|
history: PropTypes.object.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapStateToProps(state, ownProps) {
|
||||||
|
const slug = ownProps.match.params.slug;
|
||||||
|
const course =
|
||||||
|
slug && state.courses.length > 0
|
||||||
|
? getCourseBySlug(state.courses, slug)
|
||||||
|
: newCourse;
|
||||||
|
return {
|
||||||
|
course,
|
||||||
|
courses: state.courses,
|
||||||
|
authors: state.authors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCourseBySlug(courses, slug) {
|
||||||
|
return courses.find((course) => course.slug === slug) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch) {
|
||||||
|
return {
|
||||||
|
actions: bindActionCreators(
|
||||||
|
{
|
||||||
|
loadCourses,
|
||||||
|
loadAuthors,
|
||||||
|
saveCourse
|
||||||
|
},
|
||||||
|
dispatch
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(ManageCoursePage);
|
|
@ -0,0 +1,34 @@
|
||||||
|
import React from "react";
|
||||||
|
import { mount } from "enzyme";
|
||||||
|
import { authors, newCourse, courses } from "../../../tools/mockData";
|
||||||
|
import { ManageCoursePage } from "./ManageCoursePage";
|
||||||
|
|
||||||
|
function render(args) {
|
||||||
|
const defaultProps = {
|
||||||
|
authors,
|
||||||
|
courses,
|
||||||
|
// Passed from React Router in real app, so just stubbing in for test.
|
||||||
|
// Could also choose to use MemoryRouter as shown in Header.test.js,
|
||||||
|
// or even wrap with React Router, depending on whether I
|
||||||
|
// need to test React Router related behavior.
|
||||||
|
history: {},
|
||||||
|
actions: {
|
||||||
|
saveCourse: jest.fn(),
|
||||||
|
loadAuthors: jest.fn(),
|
||||||
|
loadCourses: jest.fn()
|
||||||
|
},
|
||||||
|
course: newCourse,
|
||||||
|
match: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = { ...defaultProps, ...args };
|
||||||
|
|
||||||
|
return mount(<ManageCoursePage {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sets error when attempting to save an empty title field", () => {
|
||||||
|
const wrapper = render();
|
||||||
|
wrapper.find("form").simulate("submit");
|
||||||
|
const error = wrapper.find(".alert").first();
|
||||||
|
expect(error.text()).toBe("Title is required.");
|
||||||
|
});
|
|
@ -0,0 +1,197 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`sets submit button label 'Save' when saving is false 1`] = `
|
||||||
|
<form
|
||||||
|
onSubmit={[MockFunction]}
|
||||||
|
>
|
||||||
|
<h2>
|
||||||
|
Edit
|
||||||
|
Course
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="form-group"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="title"
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="field"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
name="title"
|
||||||
|
onChange={[MockFunction]}
|
||||||
|
type="text"
|
||||||
|
value="Securing React Apps with Auth0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="form-group"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="authorId"
|
||||||
|
>
|
||||||
|
Author
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="field"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
name="authorId"
|
||||||
|
onChange={[MockFunction]}
|
||||||
|
value={1}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
Select Author
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value={1}
|
||||||
|
>
|
||||||
|
Cory House
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value={2}
|
||||||
|
>
|
||||||
|
Scott Allen
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value={3}
|
||||||
|
>
|
||||||
|
Dan Wahlin
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="form-group"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="category"
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="field"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
name="category"
|
||||||
|
onChange={[MockFunction]}
|
||||||
|
type="text"
|
||||||
|
value="JavaScript"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={false}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`sets submit button label 'Saving...' when saving is true 1`] = `
|
||||||
|
<form
|
||||||
|
onSubmit={[MockFunction]}
|
||||||
|
>
|
||||||
|
<h2>
|
||||||
|
Edit
|
||||||
|
Course
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="form-group"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="title"
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="field"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
name="title"
|
||||||
|
onChange={[MockFunction]}
|
||||||
|
type="text"
|
||||||
|
value="Securing React Apps with Auth0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="form-group"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="authorId"
|
||||||
|
>
|
||||||
|
Author
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="field"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
name="authorId"
|
||||||
|
onChange={[MockFunction]}
|
||||||
|
value={1}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
Select Author
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value={1}
|
||||||
|
>
|
||||||
|
Cory House
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value={2}
|
||||||
|
>
|
||||||
|
Scott Allen
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value={3}
|
||||||
|
>
|
||||||
|
Dan Wahlin
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="form-group"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="category"
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="field"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
name="category"
|
||||||
|
onChange={[MockFunction]}
|
||||||
|
type="text"
|
||||||
|
value="JavaScript"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={true}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Saving...
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
`;
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const HomePage = () => (
|
||||||
|
<div className="jumbotron">
|
||||||
|
<h1>Pluralsight Administration</h1>
|
||||||
|
<p>React, Redux and React Router for ultra-responsive web apps.</p>
|
||||||
|
<Link to="about" className="btn btn-primary btn-lg">
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HomePage;
|
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
|
@ -0,0 +1,8 @@
|
||||||
|
#app {
|
||||||
|
max-width: 850px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
padding: 20px 0 20px 0;
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Pluralsight Redux</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,19 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render } from "react-dom";
|
||||||
|
import { BrowserRouter as Router } from "react-router-dom";
|
||||||
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
|
import App from "./components/App";
|
||||||
|
import "./index.css";
|
||||||
|
import configureStore from "./redux/configureStore";
|
||||||
|
import { Provider as ReduxProvider } from "react-redux";
|
||||||
|
|
||||||
|
const store = configureStore();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ReduxProvider store={store}>
|
||||||
|
<Router>
|
||||||
|
<App />
|
||||||
|
</Router>
|
||||||
|
</ReduxProvider>,
|
||||||
|
document.getElementById("app")
|
||||||
|
);
|
|
@ -0,0 +1,3 @@
|
||||||
|
it("should pass", () => {
|
||||||
|
expect(true).toEqual(true);
|
||||||
|
});
|
|
@ -0,0 +1,14 @@
|
||||||
|
export const CREATE_COURSE = "CREATE_COURSE";
|
||||||
|
export const LOAD_COURSES_SUCCESS = "LOAD_COURSES_SUCCESS";
|
||||||
|
export const LOAD_AUTHORS_SUCCESS = "LOAD_AUTHORS_SUCCESS";
|
||||||
|
export const CREATE_COURSE_SUCCESS = "CREATE_COURSE_SUCCESS";
|
||||||
|
export const UPDATE_COURSE_SUCCESS = "UPDATE_COURSE_SUCCESS";
|
||||||
|
export const BEGIN_API_CALL = "BEGIN_API_CALL";
|
||||||
|
export const API_CALL_ERROR = "API_CALL_ERROR";
|
||||||
|
|
||||||
|
// By convention, actions that end in "_SUCCESS" are assumed to have been the result of a completed
|
||||||
|
// API call. But since we're doing an optimistic delete, we're hiding loading state.
|
||||||
|
// So this action name deliberately omits the "_SUCCESS" suffix.
|
||||||
|
// If it had one, our apiCallsInProgress counter would be decremented below zero
|
||||||
|
// because we're not incrementing the number of apiCallInProgress when the delete request begins.
|
||||||
|
export const DELETE_COURSE_OPTIMISTIC = "DELETE_COURSE_OPTIMISTIC";
|
|
@ -0,0 +1,9 @@
|
||||||
|
import * as types from "./actionTypes";
|
||||||
|
|
||||||
|
export function beginApiCall() {
|
||||||
|
return { type: types.BEGIN_API_CALL };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiCallError() {
|
||||||
|
return { type: types.API_CALL_ERROR };
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as types from "./actionTypes";
|
||||||
|
import * as authorApi from "../../api/authorApi";
|
||||||
|
import { beginApiCall, apiCallError } from "./apiStatusActions";
|
||||||
|
|
||||||
|
function loadAuthorsSuccess(authors) {
|
||||||
|
return { type: types.LOAD_AUTHORS_SUCCESS, authors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAuthors() {
|
||||||
|
return function (dispatch) {
|
||||||
|
dispatch(beginApiCall());
|
||||||
|
return authorApi
|
||||||
|
.getAuthors()
|
||||||
|
.then((authors) => {
|
||||||
|
dispatch(loadAuthorsSuccess(authors));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
dispatch(apiCallError(error));
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import * as types from "./actionTypes";
|
||||||
|
import * as courseApi from "../../api/courseApi";
|
||||||
|
import { beginApiCall, apiCallError } from "./apiStatusActions";
|
||||||
|
|
||||||
|
function loadCoursesSuccess(courses) {
|
||||||
|
return { type: types.LOAD_COURSES_SUCCESS, courses };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCourseSuccess(course) {
|
||||||
|
return { type: types.CREATE_COURSE_SUCCESS, course };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCourseSuccess(course) {
|
||||||
|
return { type: types.UPDATE_COURSE_SUCCESS, course };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCourseOptimistic(course) {
|
||||||
|
return { type: types.DELETE_COURSE_OPTIMISTIC, course };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCourses() {
|
||||||
|
return function (dispatch) {
|
||||||
|
dispatch(beginApiCall());
|
||||||
|
return courseApi
|
||||||
|
.getCourses()
|
||||||
|
.then((courses) => {
|
||||||
|
dispatch(loadCoursesSuccess(courses));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
dispatch(apiCallError(error));
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCourse(course) {
|
||||||
|
//eslint-disable-next-line no-unused-vars
|
||||||
|
return function (dispatch, getState) {
|
||||||
|
dispatch(beginApiCall());
|
||||||
|
return courseApi
|
||||||
|
.saveCourse(course)
|
||||||
|
.then((savedCourse) => {
|
||||||
|
course.id
|
||||||
|
? dispatch(updateCourseSuccess(savedCourse))
|
||||||
|
: dispatch(createCourseSuccess(savedCourse));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
dispatch(apiCallError(error));
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCourse(course) {
|
||||||
|
return function (dispatch) {
|
||||||
|
// Doing optimistic delete, so not dispatching begin/end api call
|
||||||
|
// actions, or apiCallError action since we're not showing the loading status for this.
|
||||||
|
dispatch(deleteCourseOptimistic(course));
|
||||||
|
return courseApi.deleteCourse(course.id);
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
import * as courseActions from "./courseActions";
|
||||||
|
import * as types from "./actionTypes";
|
||||||
|
import { courses } from "../../../tools/mockData";
|
||||||
|
import thunk from "redux-thunk";
|
||||||
|
import fetchMock from "fetch-mock";
|
||||||
|
import configureMockStore from "redux-mock-store";
|
||||||
|
|
||||||
|
// Test an async action
|
||||||
|
const middleware = [thunk];
|
||||||
|
const mockStore = configureMockStore(middleware);
|
||||||
|
|
||||||
|
describe("Async Actions", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Load Courses Thunk", () => {
|
||||||
|
it("should create BEGIN_API_CALL and LOAD_COURSES_SUCCESS when loading courses", () => {
|
||||||
|
fetchMock.mock("*", {
|
||||||
|
body: courses,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedActions = [
|
||||||
|
{ type: types.BEGIN_API_CALL },
|
||||||
|
{ type: types.LOAD_COURSES_SUCCESS, courses }
|
||||||
|
];
|
||||||
|
|
||||||
|
const store = mockStore({ courses: [] });
|
||||||
|
return store.dispatch(courseActions.loadCourses()).then(() => {
|
||||||
|
expect(store.getActions()).toEqual(expectedActions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCourseSuccess", () => {
|
||||||
|
it("should create a CREATE_COURSE_SUCCESS action", () => {
|
||||||
|
//arrange
|
||||||
|
const course = courses[0];
|
||||||
|
const expectedAction = {
|
||||||
|
type: types.CREATE_COURSE_SUCCESS,
|
||||||
|
course
|
||||||
|
};
|
||||||
|
|
||||||
|
//act
|
||||||
|
const action = courseActions.createCourseSuccess(course);
|
||||||
|
|
||||||
|
//assert
|
||||||
|
expect(action).toEqual(expectedAction);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { createStore, applyMiddleware, compose } from "redux";
|
||||||
|
import rootReducer from "./reducers";
|
||||||
|
import reduxImmutableStateInvariant from "redux-immutable-state-invariant";
|
||||||
|
import thunk from "redux-thunk";
|
||||||
|
|
||||||
|
export default function configureStore(initialState) {
|
||||||
|
const composeEnhancers =
|
||||||
|
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; //add support for Redux dev tools
|
||||||
|
|
||||||
|
return createStore(
|
||||||
|
rootReducer,
|
||||||
|
initialState,
|
||||||
|
composeEnhancers(applyMiddleware(thunk, reduxImmutableStateInvariant()))
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Use CommonJS require below so we can dynamically import during build-time.
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
module.exports = require("./configureStore.prod");
|
||||||
|
} else {
|
||||||
|
module.exports = require("./configureStore.dev");
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { createStore, applyMiddleware } from "redux";
|
||||||
|
import rootReducer from "./reducers";
|
||||||
|
import thunk from "redux-thunk";
|
||||||
|
|
||||||
|
export default function configureStore(initialState) {
|
||||||
|
return createStore(rootReducer, initialState, applyMiddleware(thunk));
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as types from "../actions/actionTypes";
|
||||||
|
import initialState from "./initialState";
|
||||||
|
|
||||||
|
function actionTypeEndsInSuccess(type) {
|
||||||
|
return type.substring(type.length - 8) === "_SUCCESS";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function apiCallStatusReducer(
|
||||||
|
state = initialState.apiCallsInProgress,
|
||||||
|
action
|
||||||
|
) {
|
||||||
|
if (action.type == types.BEGIN_API_CALL) {
|
||||||
|
return state + 1;
|
||||||
|
} else if (
|
||||||
|
action.type == types.API_CALL_ERROR ||
|
||||||
|
actionTypeEndsInSuccess(action.type)
|
||||||
|
) {
|
||||||
|
return state - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import * as types from "../actions/actionTypes";
|
||||||
|
import initialState from "./initialState";
|
||||||
|
|
||||||
|
export default function authorReducer(state = initialState.authors, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case types.LOAD_AUTHORS_SUCCESS:
|
||||||
|
return action.authors;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import * as types from "../actions/actionTypes";
|
||||||
|
import initialState from "./initialState";
|
||||||
|
|
||||||
|
export default function courseReducer(state = initialState.courses, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case types.LOAD_COURSES_SUCCESS:
|
||||||
|
return action.courses;
|
||||||
|
|
||||||
|
case types.CREATE_COURSE_SUCCESS:
|
||||||
|
return [...state, { ...action.course }];
|
||||||
|
|
||||||
|
case types.UPDATE_COURSE_SUCCESS:
|
||||||
|
return state.map((course) =>
|
||||||
|
course.id === action.course.id ? action.course : course
|
||||||
|
);
|
||||||
|
|
||||||
|
case types.DELETE_COURSE_OPTIMISTIC:
|
||||||
|
return state.filter((course) => course.id !== action.course.id);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
import courseReducer from "./courseReducer";
|
||||||
|
import * as actions from "../actions/courseActions";
|
||||||
|
|
||||||
|
it("should add course when passed CREATE_COURSE_SUCCESS", () => {
|
||||||
|
// arrange
|
||||||
|
const initialState = [
|
||||||
|
{
|
||||||
|
title: "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "B"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const newCourse = {
|
||||||
|
title: "C"
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = actions.createCourseSuccess(newCourse);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const newState = courseReducer(initialState, action);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(newState.length).toEqual(3);
|
||||||
|
expect(newState[0].title).toEqual("A");
|
||||||
|
expect(newState[1].title).toEqual("B");
|
||||||
|
expect(newState[2].title).toEqual("C");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update course when passed UPDATE_COURSE_SUCCESS", () => {
|
||||||
|
// arrange
|
||||||
|
const initialState = [
|
||||||
|
{ id: 1, title: "A" },
|
||||||
|
{ id: 2, title: "B" },
|
||||||
|
{ id: 3, title: "C" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const course = { id: 2, title: "New Title" };
|
||||||
|
const action = actions.updateCourseSuccess(course);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const newState = courseReducer(initialState, action);
|
||||||
|
const updatedCourse = newState.find((a) => a.id == course.id);
|
||||||
|
const untouchedCourse = newState.find((a) => a.id == 1);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(updatedCourse.title).toEqual("New Title");
|
||||||
|
expect(untouchedCourse.title).toEqual("A");
|
||||||
|
expect(newState.length).toEqual(3);
|
||||||
|
});
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { combineReducers } from "redux";
|
||||||
|
import courseReducer from "./courseReducer";
|
||||||
|
import authorReducer from "./authorReducer";
|
||||||
|
import apiStatusReducer from "./apiStatusReducer";
|
||||||
|
|
||||||
|
const rootReducer = combineReducers({
|
||||||
|
courses: courseReducer,
|
||||||
|
authors: authorReducer,
|
||||||
|
apiCallsInProgress: apiStatusReducer
|
||||||
|
});
|
||||||
|
|
||||||
|
export default rootReducer;
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default {
|
||||||
|
courses: [],
|
||||||
|
authors: [],
|
||||||
|
apiCallsInProgress: 0
|
||||||
|
};
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createStore } from "redux";
|
||||||
|
import rootReducer from "./reducers";
|
||||||
|
import initialState from "./reducers/initialState";
|
||||||
|
import * as courseActions from "./actions/courseActions";
|
||||||
|
|
||||||
|
it("Should handle creating courses", function () {
|
||||||
|
// arrange
|
||||||
|
const store = createStore(rootReducer, initialState);
|
||||||
|
const course = {
|
||||||
|
title: "Clean Code"
|
||||||
|
};
|
||||||
|
|
||||||
|
// act
|
||||||
|
const action = courseActions.createCourseSuccess(course);
|
||||||
|
store.dispatch(action);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const createdCourse = store.getState().courses[0];
|
||||||
|
expect(createdCourse).toEqual(course);
|
||||||
|
});
|
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
This uses json-server, but with the module approach: https://github.com/typicode/json-server#module
|
||||||
|
Downside: You can't pass the json-server command line options.
|
||||||
|
Instead, can override some defaults by passing a config object to jsonServer.defaults();
|
||||||
|
You have to check the source code to set some items.
|
||||||
|
Examples:
|
||||||
|
Validation/Customization: https://github.com/typicode/json-server/issues/266
|
||||||
|
Delay: https://github.com/typicode/json-server/issues/534
|
||||||
|
ID: https://github.com/typicode/json-server/issues/613#issuecomment-325393041
|
||||||
|
Relevant source code: https://github.com/typicode/json-server/blob/master/src/cli/run.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
const jsonServer = require("json-server");
|
||||||
|
const server = jsonServer.create();
|
||||||
|
const path = require("path");
|
||||||
|
const router = jsonServer.router(path.join(__dirname, "db.json"));
|
||||||
|
|
||||||
|
// Can pass a limited number of options to this to override (some) defaults. See https://github.com/typicode/json-server#api
|
||||||
|
const middlewares = jsonServer.defaults({
|
||||||
|
// Display json-server's built in homepage when json-server starts.
|
||||||
|
static: "node_modules/json-server/dist"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set default middlewares (logger, static, cors and no-cache)
|
||||||
|
server.use(middlewares);
|
||||||
|
|
||||||
|
// To handle POST, PUT and PATCH you need to use a body-parser. Using JSON Server's bodyParser
|
||||||
|
server.use(jsonServer.bodyParser);
|
||||||
|
|
||||||
|
// Simulate delay on all requests
|
||||||
|
server.use(function (req, res, next) {
|
||||||
|
setTimeout(next, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Declaring custom routes below. Add custom routes before JSON Server router
|
||||||
|
|
||||||
|
// Add createdAt to all POSTS
|
||||||
|
server.use((req, res, next) => {
|
||||||
|
if (req.method === "POST") {
|
||||||
|
req.body.createdAt = Date.now();
|
||||||
|
}
|
||||||
|
// Continue to JSON Server router
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.post("/courses/", function (req, res, next) {
|
||||||
|
const error = validateCourse(req.body);
|
||||||
|
if (error) {
|
||||||
|
res.status(400).send(error);
|
||||||
|
} else {
|
||||||
|
req.body.slug = createSlug(req.body.title); // Generate a slug for new courses.
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use default router
|
||||||
|
server.use(router);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const port = 3001;
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`JSON Server is running on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Centralized logic
|
||||||
|
|
||||||
|
// Returns a URL friendly slug
|
||||||
|
function createSlug(value) {
|
||||||
|
return value
|
||||||
|
.replace(/[^a-z0-9_]+/gi, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCourse(course) {
|
||||||
|
if (!course.title) return "Title is required.";
|
||||||
|
if (!course.authorId) return "Author is required.";
|
||||||
|
if (!course.category) return "Category is required.";
|
||||||
|
return "";
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const mockData = require("./mockData");
|
||||||
|
|
||||||
|
const { courses, authors } = mockData;
|
||||||
|
const data = JSON.stringify({ courses, authors });
|
||||||
|
const filepath = path.join(__dirname, "db.json");
|
||||||
|
|
||||||
|
fs.writeFile(filepath, data, function (err) {
|
||||||
|
err ? console.log(err) : console.log("Mock DB created.");
|
||||||
|
});
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Mocks file imports for Jest. As suggested by https://jestjs.io/docs/en/webpack
|
||||||
|
module.exports = "test-file-stub";
|
|
@ -0,0 +1,92 @@
|
||||||
|
const courses = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Securing React Apps with Auth0",
|
||||||
|
slug: "react-auth0-authentication-security",
|
||||||
|
authorId: 1,
|
||||||
|
category: "JavaScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "React: The Big Picture",
|
||||||
|
slug: "react-big-picture",
|
||||||
|
authorId: 1,
|
||||||
|
category: "JavaScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Creating Reusable React Components",
|
||||||
|
slug: "react-creating-reusable-components",
|
||||||
|
authorId: 1,
|
||||||
|
category: "JavaScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: "Building a JavaScript Development Environment",
|
||||||
|
slug: "javascript-development-environment",
|
||||||
|
authorId: 1,
|
||||||
|
category: "JavaScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: "Building Applications with React and Redux",
|
||||||
|
slug: "react-redux-react-router-es6",
|
||||||
|
authorId: 1,
|
||||||
|
category: "JavaScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: "Building Applications in React and Flux",
|
||||||
|
slug: "react-flux-building-applications",
|
||||||
|
authorId: 1,
|
||||||
|
category: "JavaScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: "Clean Code: Writing Code for Humans",
|
||||||
|
slug: "writing-clean-code-humans",
|
||||||
|
authorId: 1,
|
||||||
|
category: "Software Practices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: "Architecting Applications for the Real World",
|
||||||
|
slug: "architecting-applications-dotnet",
|
||||||
|
authorId: 1,
|
||||||
|
category: "Software Architecture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: "Becoming an Outlier: Reprogramming the Developer Mind",
|
||||||
|
slug: "career-reboot-for-developer-mind",
|
||||||
|
authorId: 1,
|
||||||
|
category: "Career"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: "Web Component Fundamentals",
|
||||||
|
slug: "web-components-shadow-dom",
|
||||||
|
authorId: 1,
|
||||||
|
category: "HTML5"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const authors = [
|
||||||
|
{ id: 1, name: "Cory House" },
|
||||||
|
{ id: 2, name: "Scott Allen" },
|
||||||
|
{ id: 3, name: "Dan Wahlin" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const newCourse = {
|
||||||
|
id: null,
|
||||||
|
title: "",
|
||||||
|
authorId: null,
|
||||||
|
category: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
// Using CommonJS style export so we can consume via Node (without using Babel-node)
|
||||||
|
module.exports = {
|
||||||
|
newCourse,
|
||||||
|
courses,
|
||||||
|
authors
|
||||||
|
};
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Mocks CSS imports for Jest. As suggested by https://jestjs.io/docs/en/webpack
|
||||||
|
module.exports = {};
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { configure } from "enzyme";
|
||||||
|
import Adapter from "enzyme-adapter-react-16";
|
||||||
|
configure({ adapter: new Adapter() });
|
|
@ -0,0 +1,47 @@
|
||||||
|
const webpack = require("webpack");
|
||||||
|
const path = require("path");
|
||||||
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
|
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: "development",
|
||||||
|
target: "web",
|
||||||
|
devtool: "cheap-module-source-map",
|
||||||
|
entry: "./src/index",
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, "build"),
|
||||||
|
publicPath: "/",
|
||||||
|
filename: "bundle.js"
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
stats: "minimal",
|
||||||
|
overlay: true,
|
||||||
|
historyApiFallback: true,
|
||||||
|
disableHostCheck: true,
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
https: false
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
"process.env.API_URL": JSON.stringify("http://localhost:3001")
|
||||||
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: "src/index.html",
|
||||||
|
favicon: "src/favicon.ico"
|
||||||
|
})
|
||||||
|
],
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.(js|jsx)$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: ["babel-loader", "eslint-loader"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /(\.css)$/,
|
||||||
|
use: ["style-loader", "css-loader"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,78 @@
|
||||||
|
const webpack = require("webpack");
|
||||||
|
const path = require("path");
|
||||||
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
|
const webpackBundleAnalyzer = require("webpack-bundle-analyzer");
|
||||||
|
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: "production",
|
||||||
|
target: "web",
|
||||||
|
devtool: "source-map",
|
||||||
|
entry: "./src/index",
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, "build"),
|
||||||
|
publicPath: "/",
|
||||||
|
filename: "bundle.js"
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
// Display bundle stats
|
||||||
|
new webpackBundleAnalyzer.BundleAnalyzerPlugin({ analyzerMode: "static" }),
|
||||||
|
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: "[name].[contenthash].css"
|
||||||
|
}),
|
||||||
|
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
// This global makes sure React is built in prod mode.
|
||||||
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
|
||||||
|
"process.env.API_URL": JSON.stringify("http://localhost:3001")
|
||||||
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: "src/index.html",
|
||||||
|
favicon: "src/favicon.ico",
|
||||||
|
minify: {
|
||||||
|
// see https://github.com/kangax/html-minifier#options-quick-reference
|
||||||
|
removeComments: true,
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeRedundantAttributes: true,
|
||||||
|
useShortDoctype: true,
|
||||||
|
removeEmptyAttributes: true,
|
||||||
|
removeStyleLinkTypeAttributes: true,
|
||||||
|
keepClosingSlash: true,
|
||||||
|
minifyJS: true,
|
||||||
|
minifyCSS: true,
|
||||||
|
minifyURLs: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.(js|jsx)$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: ["babel-loader", "eslint-loader"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /(\.css)$/,
|
||||||
|
use: [
|
||||||
|
MiniCssExtractPlugin.loader,
|
||||||
|
{
|
||||||
|
loader: "css-loader",
|
||||||
|
options: {
|
||||||
|
sourceMap: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: "postcss-loader",
|
||||||
|
options: {
|
||||||
|
plugins: () => [require("cssnano")],
|
||||||
|
sourceMap: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue