initial commit

master
Tudor Stanciu 2020-04-14 10:53:19 +03:00
commit 5ab7727de5
57 changed files with 15695 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
node_modules
build
__mocks__
.vscode
helm

39
.gitignore vendored Normal file
View File

@ -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

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": false
}

16
Dockerfile Normal file
View File

@ -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"]

70
README.md Normal file
View File

@ -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 |

13778
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

116
package.json Normal file
View File

@ -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
}
}

17
src/api/apiUtils.js Normal file
View File

@ -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;
}

6
src/api/authorApi.js Normal file
View File

@ -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);
}

22
src/api/courseApi.js Normal file
View File

@ -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);
}

29
src/components/App.js Normal file
View File

@ -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;

View File

@ -0,0 +1,5 @@
import React from "react";
const PageNotFound = () => <h1>Oops! Page not found</h1>;
export default PageNotFound;

View File

@ -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;

View File

@ -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;

View File

@ -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);
});

View File

@ -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;

View File

@ -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);
}
}

View File

@ -0,0 +1,8 @@
import React from "react";
import "./Spinner.css";
const Spinner = () => {
return <div className="loader">Loading...</div>;
};
export default Spinner;

View File

@ -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;

View File

@ -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...");
});

View File

@ -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...");
});

View File

@ -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();
});

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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.");
});

View File

@ -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>
`;

View File

@ -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;

BIN
src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

8
src/index.css Normal file
View File

@ -0,0 +1,8 @@
#app {
max-width: 850px;
margin: 0 auto;
}
nav {
padding: 20px 0 20px 0;
}

11
src/index.html Normal file
View File

@ -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>

19
src/index.js Normal file
View File

@ -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")
);

3
src/index.test.js Normal file
View File

@ -0,0 +1,3 @@
it("should pass", () => {
expect(true).toEqual(true);
});

View File

@ -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";

View File

@ -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 };
}

View File

@ -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;
});
};
}

View File

@ -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);
};
}

View File

@ -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);
});
});

View File

@ -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()))
);
}

View File

@ -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");
}

View File

@ -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));
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
});

View File

@ -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;

View File

@ -0,0 +1,5 @@
export default {
courses: [],
authors: [],
apiCallsInProgress: 0
};

20
src/redux/store.test.js Normal file
View File

@ -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);
});

81
tools/apiServer.js Normal file
View File

@ -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 "";
}

12
tools/createMockDb.js Normal file
View File

@ -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.");
});

2
tools/fileMock.js Normal file
View File

@ -0,0 +1,2 @@
// Mocks file imports for Jest. As suggested by https://jestjs.io/docs/en/webpack
module.exports = "test-file-stub";

92
tools/mockData.js Normal file
View File

@ -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
};

2
tools/styleMock.js Normal file
View File

@ -0,0 +1,2 @@
// Mocks CSS imports for Jest. As suggested by https://jestjs.io/docs/en/webpack
module.exports = {};

3
tools/testSetup.js Normal file
View File

@ -0,0 +1,3 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
configure({ adapter: new Adapter() });

47
webpack.config.dev.js Normal file
View File

@ -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"]
}
]
}
};

78
webpack.config.prod.js Normal file
View File

@ -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
}
}
]
}
]
}
};