├── src
├── api
│ ├── delay.ts
│ ├── mockAuthorApi.ts
│ └── mockCourseApi.ts
├── lib
│ └── history.ts
├── redux
│ ├── reducers
│ │ ├── authorModel.ts
│ │ ├── authorSelectors.ts
│ │ ├── courseModel.ts
│ │ ├── index.ts
│ │ ├── authorReducer.ts
│ │ ├── ajaxStatusReducer.ts
│ │ ├── authorSelectors.test.ts
│ │ ├── courseReducer.ts
│ │ └── courseReducer.test.ts
│ ├── actions
│ │ ├── ajaxStatusActions.ts
│ │ ├── actionTypes.ts
│ │ ├── authorActions.ts
│ │ ├── courseActions.test.ts
│ │ └── courseActions.ts
│ └── store
│ │ ├── store.test.ts
│ │ └── configureStore.ts
├── setupTests.ts
├── App.css
├── components
│ ├── about
│ │ └── AboutPage.tsx
│ ├── home
│ │ └── HomePage.tsx
│ ├── course
│ │ ├── CourseListRow.tsx
│ │ ├── CourseList.tsx
│ │ ├── CoursesPage.tsx
│ │ ├── ManageCoursePage.test.tsx
│ │ ├── CourseForm.test.tsx
│ │ ├── CourseForm.tsx
│ │ └── ManageCoursePage.tsx
│ └── common
│ │ ├── Header.tsx
│ │ ├── LoadingDots.tsx
│ │ ├── TextInput.tsx
│ │ └── SelectInput.tsx
├── utils
│ └── executionUtils.ts
├── App.tsx
├── index.tsx
├── logo.svg
└── registerServiceWorker.ts
├── public
├── favicon.ico
├── manifest.json
└── index.html
├── .storybook
├── addons.js
└── config.js
├── tsconfig.test.json
├── README.md
├── .prettierrc
├── .gitignore
├── stories
└── index.stories.js
├── tsconfig.json
├── package.json
└── tslint.json
/src/api/delay.ts:
--------------------------------------------------------------------------------
1 | export default 1000;
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/540/react-redux-thunk-sample/master/public/favicon.ico
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-actions/register';
2 | import '@storybook/addon-links/register';
3 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs"
5 | }
6 | }
--------------------------------------------------------------------------------
/src/lib/history.ts:
--------------------------------------------------------------------------------
1 | import createHistory from 'history/createBrowserHistory';
2 |
3 | const history = createHistory();
4 | export default history;
--------------------------------------------------------------------------------
/src/redux/reducers/authorModel.ts:
--------------------------------------------------------------------------------
1 | export type Author = Readonly<{
2 | id: string
3 | firstName: string;
4 | lastName: string;
5 | }>;
6 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme';
2 | import * as Adapter from 'enzyme-adapter-react-16';
3 | import 'jest-enzyme'
4 |
5 | configure({adapter: new Adapter()});
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-redux-thunk-sample
2 | React redux thunk sample following Pluralsight "Building applications with react course"
3 |
4 | Uses:
5 | - Typescript
6 | - Create react app (Biko version)
7 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "trailingComma": "all",
8 | "bracketSpacing": true,
9 | "jsxBracketSameLine": false
10 | }
11 |
--------------------------------------------------------------------------------
/src/redux/actions/ajaxStatusActions.ts:
--------------------------------------------------------------------------------
1 | import ActionTypes from './actionTypes';
2 |
3 | export const beginAjaxCall = () =>
4 | ({type: ActionTypes.BeginAjaxCall});
5 |
6 | export const ajaxCallError = () =>
7 | ({type: ActionTypes.AjaxCallError});
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
3 | color: #4d4d4d;
4 | min-width: 550px;
5 | max-width: 850px;
6 | margin: 0 auto;
7 | }
8 |
9 | a.active {
10 | color: orange;
11 | }
12 |
13 | nav {
14 | padding-top: 20px;
15 | }
--------------------------------------------------------------------------------
/src/redux/reducers/authorSelectors.ts:
--------------------------------------------------------------------------------
1 | import { Author } from './authorModel';
2 | import { Option } from '../../components/common/SelectInput';
3 |
4 | export const authorsFormattedForDropDown = (authors: Author[]): Option[] =>
5 | authors.map(author => ({value: author.id, text: author.firstName + ' ' + author.lastName}));
6 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react';
2 |
3 | // automatically import all files ending in *.stories.js
4 | const req = require.context('../stories', true, /.stories.js$/);
5 | function loadStories() {
6 | req.keys().forEach((filename) => req(filename));
7 | }
8 |
9 | configure(loadStories, module);
10 |
--------------------------------------------------------------------------------
/src/redux/reducers/courseModel.ts:
--------------------------------------------------------------------------------
1 | export type Course = Readonly<{
2 | id: string
3 | title: string;
4 | watchHref: string;
5 | authorId: string;
6 | category: string;
7 | length: string;
8 | }>;
9 |
10 | export const builder = {
11 | empty: () => ({id: '', watchHref: '', title: '', authorId: '', length: '', category: ''})
12 | };
13 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | .idea
24 |
--------------------------------------------------------------------------------
/src/components/about/AboutPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { StatelessComponent } from 'react';
3 |
4 | const AboutPage: StatelessComponent<{}> = () => {
5 | return (
6 |
7 |
About
8 |
This application uses React, Redux Router and a variety of other helpful libraries
9 |
10 | );
11 | };
12 |
13 | export default AboutPage;
--------------------------------------------------------------------------------
/src/redux/actions/actionTypes.ts:
--------------------------------------------------------------------------------
1 | enum ActionTypes {
2 | CreateCourse = 'CREATE_COURSE',
3 | LoadCoursesSuccess = 'LOAD_COURSES_SUCCESS',
4 | CreateCourseSuccess = 'CREATE_COURSE_SUCCESS',
5 | UpdateCourseSuccess = 'UPDATE_COURSE_SUCCESS',
6 | LoadAuthorsSuccess = 'LOAD_AUTHORS_SUCCESS',
7 | BeginAjaxCall = 'BEGIN_AJAX_CALL',
8 | AjaxCallError = 'AJAX_CALL_ERROR'
9 | }
10 |
11 | export default ActionTypes;
--------------------------------------------------------------------------------
/src/utils/executionUtils.ts:
--------------------------------------------------------------------------------
1 | export const onDev = (fn: Function, arg: T) => {
2 | if (process.env.NODE_ENV === 'production') {
3 | return arg;
4 | }
5 |
6 | return fn(arg);
7 | };
8 |
9 | // (fn: F): F | undefined => {
10 |
11 | export const onProd = (fn: Function, arg: T) => {
12 | if (process.env.NODE_ENV !== 'production') {
13 | return arg;
14 | }
15 |
16 | return fn(arg);
17 | };
--------------------------------------------------------------------------------
/src/components/home/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | class HomePage extends React.Component {
5 | render() {
6 | return (
7 |
8 |
Pluralsight Administration
9 |
React, Redux and React Router in ES6 for ultra-responsive web apps.
10 |
Learn more
11 |
12 | );
13 | }
14 | }
15 |
16 | export default HomePage;
--------------------------------------------------------------------------------
/src/redux/reducers/index.ts:
--------------------------------------------------------------------------------
1 | import courses from './courseReducer';
2 | import authors from './authorReducer';
3 | import ajaxCallsInProgress from './ajaxStatusReducer';
4 | import { Course } from './courseModel';
5 | import { Author } from './authorModel';
6 |
7 | export interface State {
8 | courses: Course[];
9 | authors: Author[];
10 | ajaxCallsInProgress: number;
11 | }
12 |
13 | export const initialState = {
14 | authors: [],
15 | courses: [],
16 | ajaxCallsInProgress: 0
17 | };
18 |
19 | export default {
20 | courses,
21 | authors,
22 | ajaxCallsInProgress
23 | };
--------------------------------------------------------------------------------
/stories/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { storiesOf } from '@storybook/react';
4 | import { action } from '@storybook/addon-actions';
5 | import { linkTo } from '@storybook/addon-links';
6 |
7 | import { Button, Welcome } from '@storybook/react/demo';
8 |
9 | storiesOf('Welcome', module).add('to Storybook', () => );
10 |
11 | storiesOf('Button', module)
12 | .add('with text', () => )
13 | .add('with some emoji', () => );
14 |
--------------------------------------------------------------------------------
/src/redux/reducers/authorReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action } from 'redux';
2 | import { Author } from './authorModel';
3 | import { initialState } from './index';
4 | import ActionTypes from '../actions/actionTypes';
5 |
6 | type LoadAuthorsAction = Action & {
7 | authors: Author[];
8 | };
9 |
10 | const authorReducer = (state: Author[] = initialState.authors, action: LoadAuthorsAction) => {
11 | switch (action.type) {
12 | case ActionTypes.LoadAuthorsSuccess:
13 | return action.authors;
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | export default authorReducer;
--------------------------------------------------------------------------------
/src/redux/actions/authorActions.ts:
--------------------------------------------------------------------------------
1 | import authorApi from '../../api/mockAuthorApi';
2 | import types from './actionTypes';
3 | import { Author } from '../reducers/authorModel';
4 | import { Dispatch } from 'react-redux';
5 | import { Action } from 'redux';
6 | import { beginAjaxCall } from './ajaxStatusActions';
7 |
8 | const loadAuthorsSuccess = (authors: Author[]) =>
9 | ({type: types.LoadAuthorsSuccess, authors});
10 |
11 | export const loadAuthors = () => async (dispatch: Dispatch) => {
12 | dispatch(beginAjaxCall());
13 |
14 | const authors: Author[] = await authorApi.getAllAuthors();
15 | dispatch(loadAuthorsSuccess(authors));
16 | };
--------------------------------------------------------------------------------
/src/redux/store/store.test.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers, createStore } from 'redux';
2 | import reducers, { initialState } from '../reducers';
3 | import * as courseActions from '../actions/courseActions';
4 |
5 | describe('Store', function () {
6 | it('should handle creating courses', function () {
7 | // arrange
8 | const store = createStore(combineReducers(reducers), initialState);
9 | const course = {title: 'Clean Code'};
10 |
11 | store.dispatch(courseActions.createCourseSuccess(course));
12 |
13 | const actual = store.getState().courses[0];
14 | expect(actual).toEqual({title: 'Clean Code'});
15 | });
16 | });
--------------------------------------------------------------------------------
/src/redux/reducers/ajaxStatusReducer.ts:
--------------------------------------------------------------------------------
1 | import ActionTypes from '../actions/actionTypes';
2 | import { initialState } from './index';
3 | import { Action } from 'redux';
4 |
5 | const actionTypeEndsInSuccess = (type: ActionTypes) =>
6 | type.valueOf().endsWith('_SUCCESS');
7 |
8 | const ajaxStatusReducer = (state: number = initialState.ajaxCallsInProgress, action: Action) => {
9 | if (action.type === ActionTypes.BeginAjaxCall) {
10 | return state + 1;
11 | } else if (actionTypeEndsInSuccess(action.type) || action.type === ActionTypes.AjaxCallError) {
12 | return state - 1;
13 | }
14 |
15 | return state;
16 | };
17 |
18 | export default ajaxStatusReducer;
--------------------------------------------------------------------------------
/src/components/course/CourseListRow.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Course } from '../../redux/reducers/courseModel';
3 | import { Link } from 'react-router-dom';
4 |
5 | type Props = {
6 | course: Course
7 | };
8 |
9 | const CourseListRow: React.StatelessComponent = ({course}) => {
10 | return (
11 |
12 | | Watch |
13 | {course.title} |
14 | {course.authorId} |
15 | {course.category} |
16 | {course.length} |
17 |
18 | );
19 | };
20 |
21 | export default CourseListRow;
--------------------------------------------------------------------------------
/src/components/common/Header.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { NavLink } from 'react-router-dom';
3 | import LoadingDots from './LoadingDots';
4 |
5 | type Props = { loading: boolean };
6 |
7 | const Header: React.StatelessComponent = ({loading}) => {
8 | return (
9 |
17 | );
18 | };
19 |
20 | export default Header;
--------------------------------------------------------------------------------
/src/redux/reducers/authorSelectors.test.ts:
--------------------------------------------------------------------------------
1 | import { authorsFormattedForDropdown } from './authorSelectors';
2 |
3 | describe('Author Selectors', () => {
4 | describe('authorsFormattedForDropdown', () => {
5 | it('should return author data formatted for use in a dropdown', () => {
6 | const authors = [
7 | {id: 'cory-house', firstName: 'Cory', lastName: 'House'},
8 | {id: 'scott-allen', firstName: 'Scott', lastName: 'Allen'}
9 | ];
10 |
11 | const expected = [
12 | {value: 'cory-house', text: 'Cory House'},
13 | {value: 'scott-allen', text: 'Scott Allen'}
14 | ];
15 |
16 | expect(authorsFormattedForDropdown(authors)).toEqual(expected);
17 | });
18 | });
19 | });
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "outDir": "build/dist",
5 | "module": "esnext",
6 | "target": "es5",
7 | "lib": ["es6", "dom"],
8 | "sourceMap": true,
9 | "allowJs": true,
10 | "jsx": "react",
11 | "moduleResolution": "node",
12 | "rootDir": "src",
13 | "forceConsistentCasingInFileNames": true,
14 | "noImplicitReturns": true,
15 | "noImplicitThis": true,
16 | "noImplicitAny": true,
17 | "strictNullChecks": true,
18 | "suppressImplicitAnyIndexErrors": true,
19 | "noUnusedLocals": true
20 | },
21 | "exclude": [
22 | "node_modules",
23 | "build",
24 | "scripts",
25 | "acceptance-tests",
26 | "webpack",
27 | "jest",
28 | "src/setupTests.ts",
29 | "**/*.test.ts",
30 | "**/*.test.tsx"
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/course/CourseList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Course } from '../../redux/reducers/courseModel';
3 | import CourseListRow from './CourseListRow';
4 |
5 | type Props = {
6 | courses: Course[]
7 | };
8 |
9 | const CourseList: React.StatelessComponent = ({courses}) => {
10 | return (
11 |
12 |
13 |
14 | | |
15 | Title |
16 | Author |
17 | Category |
18 | Length |
19 |
20 |
21 |
22 | {courses.map(course => ())}
23 |
24 |
25 | );
26 | };
27 |
28 | export default CourseList;
--------------------------------------------------------------------------------
/src/components/course/CoursesPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import CourseList from './CourseList';
4 | import { Course } from '../../redux/reducers/courseModel';
5 | import { Link } from 'react-router-dom';
6 |
7 | type Props = { courses: Course[] };
8 |
9 | class CoursesPage extends React.Component {
10 | constructor(props: Props) {
11 | super(props);
12 | }
13 |
14 | render() {
15 | const {courses} = this.props;
16 |
17 | return (
18 | <>
19 | Courses
20 | Add Course
21 |
22 | >
23 | );
24 | }
25 | }
26 |
27 | const mapStateToProps = (state: { courses: Course[] }) =>
28 | ({courses: state.courses});
29 |
30 | export default connect(mapStateToProps)(CoursesPage);
--------------------------------------------------------------------------------
/src/components/course/ManageCoursePage.test.tsx:
--------------------------------------------------------------------------------
1 | import { ManageCoursePage } from './ManageCoursePage';
2 | import { mount } from 'enzyme';
3 | import * as React from 'react';
4 | import { builder as CourseBuilder } from '../../redux/reducers/courseModel';
5 |
6 | describe('Manage course page', () => {
7 |
8 | it('sets error message when trying to save an empty title', () => {
9 | const saveCourse = jest.fn();
10 | const props = {
11 | course: CourseBuilder.empty(),
12 | authors: [],
13 | actions: {
14 | saveCourse,
15 | loadCourses: () => {
16 | }
17 | }
18 | }
19 | const wrapper = mount();
20 | const saveForm = wrapper.find('form');
21 |
22 | saveForm.simulate('submit');
23 |
24 | expect(wrapper.state().errors.title).toBe('Title must be at least 5 characters.');
25 | expect(saveCourse).not.toBeCalled();
26 | });
27 |
28 | });
--------------------------------------------------------------------------------
/src/components/common/LoadingDots.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | type Props = Readonly<{
4 | interval?: number;
5 | dots?: number;
6 | }>;
7 |
8 | type State = Readonly<{
9 | frame: number;
10 | }>;
11 |
12 | class LoadingDots extends React.Component {
13 |
14 | static defaultProps: Props = {
15 | interval: 300,
16 | dots: 3
17 | };
18 |
19 | private interval: number;
20 |
21 | constructor(props: Props) {
22 | super(props);
23 |
24 | this.state = {frame: 1};
25 | }
26 |
27 | componentDidMount() {
28 | this.interval = setInterval(
29 | () => this.setState({frame: this.state.frame + 1}),
30 | this.props.interval);
31 | }
32 |
33 | componentWillUnmount() {
34 | clearInterval(this.interval);
35 | }
36 |
37 | render() {
38 | let dots = this.state.frame % (this.props.dots! + 1);
39 | let text = '';
40 | while (dots > 0) {
41 | text += '.';
42 | dots--;
43 | }
44 | return {text} ;
45 | }
46 | }
47 |
48 | export default LoadingDots;
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ReactNode } from 'react';
3 | import './App.css';
4 | import Header from './components/common/Header';
5 | import { connect } from 'react-redux';
6 | import { RouteComponentProps, withRouter } from 'react-router-dom';
7 | import * as Notifications from 'react-notification-system-redux';
8 | import { Notification } from 'react-notification-system';
9 |
10 | type Props = Readonly<{
11 | loading: boolean,
12 | children: ReactNode
13 | notifications: Notification[],
14 | }> & RouteComponentProps<{}>;
15 |
16 | const App: React.StatelessComponent = ({loading, children, notifications}) => {
17 | return (
18 |
19 |
20 | {children}
21 |
24 |
25 | );
26 | };
27 |
28 | const mapStateToProps = (state: { ajaxCallsInProgress: number, notifications: Notification[] }) => {
29 | return {
30 | loading: state.ajaxCallsInProgress > 0,
31 | notifications: state.notifications
32 | };
33 | };
34 |
35 | export default withRouter(connect(mapStateToProps)(App));
36 |
--------------------------------------------------------------------------------
/src/redux/reducers/courseReducer.ts:
--------------------------------------------------------------------------------
1 | import { Course } from './courseModel';
2 | import ActionTypes from '../actions/actionTypes';
3 | import { initialState } from './index';
4 | import { Action } from 'redux';
5 |
6 | type CoursesLoadAction = Action & { courses: Course[] };
7 | type CourseCreateAction = Action & { course: Course };
8 | type CourseUpdateAction = Action & { course: Course };
9 |
10 | const courseReducer = (state: Course[] = initialState.courses, action: Action) => {
11 | switch (action.type) {
12 | case ActionTypes.LoadCoursesSuccess:
13 | return ( action).courses;
14 | case ActionTypes.CreateCourseSuccess:
15 | return [
16 | ...state,
17 | ( action).course
18 | ];
19 | case ActionTypes.UpdateCourseSuccess:
20 | const updateAction = action;
21 | return [
22 | ...state.filter(course => course.id !== updateAction.course.id),
23 | updateAction.course
24 | ].sort((a: Course, b: Course) => a.id.localeCompare(b.id));
25 | default:
26 | return state;
27 | }
28 | };
29 |
30 | export default courseReducer;
--------------------------------------------------------------------------------
/src/components/course/CourseForm.test.tsx:
--------------------------------------------------------------------------------
1 | import CourseForm from './CourseForm';
2 | import { mount, ShallowWrapper } from 'enzyme';
3 | import * as React from 'react';
4 | import { builder as CourseBuilder } from '../../redux/reducers/courseModel';
5 |
6 | describe('CourseForm test', () => {
7 |
8 | let props, wrapper: ShallowWrapper;
9 | beforeEach(() => {
10 | props = {
11 | course: CourseBuilder.empty(),
12 | saving: false,
13 | allAuthors: [],
14 | errors: {},
15 | onSave: () => {
16 | },
17 | onChange: () => {
18 | }
19 | };
20 |
21 | wrapper = render();
22 | });
23 |
24 | it('renders form and h1', () => {
25 | expect(wrapper.find('form').length).toBe(1);
26 | expect(wrapper.find('h1').text()).toEqual('Manage Course');
27 | });
28 |
29 | it('save button is labeled "Save" when not saving', () => {
30 | expect(wrapper.find('input').props().value).toBe('Save');
31 | });
32 |
33 | it('save button is labeled "Saving..." when saving', () => {
34 | wrapper.setProps({saving: true});
35 |
36 | expect(wrapper.find('input').props().value).toBe('Saving...');
37 | });
38 | });
--------------------------------------------------------------------------------
/src/components/common/TextInput.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | type Props = Readonly<{
4 | name: string;
5 | label: string;
6 | onChange: (field: string, value: string) => void;
7 | value?: string;
8 | placeholder?: string;
9 | error?: string;
10 | }>;
11 |
12 | const TextIntput: React.StatelessComponent = props => {
13 | let wrapperClass = 'form-group';
14 | if (props.error && props.error.length > 0) {
15 | wrapperClass += ' is-invalid';
16 | }
17 |
18 | return (
19 |
20 |
21 |
22 |
0 ? ' is-invalid' : '')}
26 | placeholder={props.placeholder}
27 | onChange={event => props.onChange(event.target.name, event.target.value)}
28 | value={props.value}
29 | />
30 |
{props.error}
31 |
32 |
33 | );
34 | };
35 |
36 | export default TextIntput;
--------------------------------------------------------------------------------
/src/redux/store/configureStore.ts:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, combineReducers, createStore, Middleware } from 'redux';
2 | import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
3 | import { onDev } from '../../utils/executionUtils';
4 | import thunk from 'redux-thunk';
5 | import { Author } from '../reducers/authorModel';
6 | import { Course } from '../reducers/courseModel';
7 | import { routerMiddleware, routerReducer } from 'react-router-redux';
8 | import reducers from '../reducers';
9 | import history from '../../lib/history';
10 | import { reducer as notifications } from 'react-notification-system-redux';
11 |
12 | type State = Readonly<{
13 | authors: Author[],
14 | courses: Course[],
15 | }>;
16 |
17 | const configureStore = (initialState: State) => {
18 | let middleware: Middleware[] = [
19 | thunk,
20 | routerMiddleware(history),
21 | ];
22 |
23 | return createStore(
24 | combineReducers(
25 | {
26 | ...reducers,
27 | router: routerReducer,
28 | notifications
29 | }
30 | ),
31 | initialState,
32 | onDev(composeWithDevTools({}), applyMiddleware(...middleware))
33 | );
34 | };
35 |
36 | export default configureStore;
--------------------------------------------------------------------------------
/src/components/common/SelectInput.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | type Props = Readonly<{
4 | name: string;
5 | label: string;
6 | onChange: (fieldName: string, value: string) => void;
7 | defaultOption?: string;
8 | value?: string;
9 | options: Option[];
10 | error?: string;
11 | }>;
12 |
13 | export type Option = Readonly<{
14 | value: string;
15 | text: string;
16 | }>;
17 |
18 | const SelectInput: React.StatelessComponent = props => {
19 | return (
20 |
21 |
22 |
23 |
35 | {props.error &&
{props.error}
}
36 |
37 |
38 | );
39 | };
40 |
41 | SelectInput.defaultProps = {
42 | options: []
43 | };
44 |
45 | export default SelectInput;
--------------------------------------------------------------------------------
/src/redux/reducers/courseReducer.test.ts:
--------------------------------------------------------------------------------
1 | import courseReducer from './courseReducer';
2 | import * as actions from '../actions/courseActions'
3 |
4 | describe('Course Reducer', () => {
5 | it('should add course when passed CREATE_COURSE_SUCCESS', () => {
6 | const initialState = [
7 | {title: 'A'},
8 | {title: 'B'}
9 | ];
10 | const newCourse = {title: 'C'};
11 |
12 | const newState = courseReducer(initialState, actions.createCourseSuccess(newCourse));
13 |
14 | expect(newState.length).toEqual(3);
15 | expect(newState[0].title).toEqual('A');
16 | expect(newState[1].title).toEqual('B');
17 | expect(newState[2].title).toEqual('C');
18 | });
19 |
20 | it('should update course when passed UPDATE_COURSE_SUCCESS', () => {
21 | // arrange
22 | const initialState = [
23 | {id: 'A', title: 'A'},
24 | {id: 'B', title: 'B'},
25 | {id: 'C', title: 'C'}
26 | ];
27 |
28 | const course = {id: 'B', title: 'New Title'};
29 |
30 | // act
31 | const newState = courseReducer(initialState, actions.updateCourseSuccess(course));
32 | const updatedCourse = newState.find(a => a.id == course.id);
33 | const untouchedCourse = newState.find(a => a.id == 'A');
34 |
35 | // assert
36 | expect(updatedCourse.title).toEqual('New Title');
37 | expect(untouchedCourse.title).toEqual('A');
38 | expect(newState.length).toEqual(3);
39 | });
40 | });
--------------------------------------------------------------------------------
/src/redux/actions/courseActions.test.ts:
--------------------------------------------------------------------------------
1 | import thunk from 'redux-thunk';
2 | import * as courseActions from '../actions/courseActions';
3 | import ActionTypes from '../actions/actionTypes';
4 | import configureMockStore from 'redux-mock-store';
5 |
6 | describe('Course Actions', () => {
7 | describe('createCourseSuccess', () => {
8 | it('should create a CREATE_COURSE_SUCCESS action', () => {
9 | const course = {id: 'clean-code', title: 'Clean Code'};
10 | const expectedAction = {type: ActionTypes.CreateCourseSuccess, course: course};
11 |
12 | const action = courseActions.createCourseSuccess(course);
13 |
14 | expect(action).toEqual(expectedAction);
15 | });
16 | });
17 | });
18 |
19 | const mockStore = configureMockStore([thunk]);
20 |
21 | describe('Async Actions', () => {
22 | it('should create BEGIN_AJAX_CALL and LOAD_COURSES_SUCCESS when loading courses', (done) => {
23 | const expectedActions = [
24 | {type: ActionTypes.BeginAjaxCall},
25 | {type: ActionTypes.LoadCoursesSuccess, body: {courses: [{id: 'clean-code', title: 'Clean Code'}]}}
26 | ];
27 |
28 | const store = mockStore({courses: []}, expectedActions);
29 | store.dispatch(courseActions.loadCourses()).then(() => {
30 | const actions = store.getActions();
31 | expect(actions[0].type).toEqual(ActionTypes.BeginAjaxCall);
32 | expect(actions[1].type).toEqual(ActionTypes.LoadCoursesSuccess);
33 | done();
34 | });
35 | });
36 | });
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import { Route, Switch } from 'react-router-dom';
4 | import App from './App';
5 | import registerServiceWorker from './registerServiceWorker';
6 | import { Provider } from 'react-redux';
7 | import HomePage from './components/home/HomePage';
8 | import AboutPage from './components/about/AboutPage';
9 | import CoursesPage from './components/course/CoursesPage';
10 | import ManageCoursePage from './components/course/ManageCoursePage';
11 | import configureStore from './redux/store/configureStore';
12 | import { loadCourses } from './redux/actions/courseActions';
13 | import { loadAuthors } from './redux/actions/authorActions';
14 | import { initialState } from './redux/reducers/index';
15 | import { ConnectedRouter } from 'react-router-redux';
16 | import history from './lib/history';
17 |
18 | const store = configureStore(initialState);
19 |
20 | store.dispatch(loadCourses());
21 | store.dispatch(loadAuthors());
22 |
23 | ReactDOM.render(
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | ,
37 | document.getElementById('root') as HTMLElement
38 | );
39 |
40 | registerServiceWorker();
41 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | React App
23 |
24 |
28 |
29 |
30 |
33 |
34 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/redux/actions/courseActions.ts:
--------------------------------------------------------------------------------
1 | import courseApi from '../../api/mockCourseApi';
2 | import types from './actionTypes';
3 | import { Course } from '../reducers/courseModel';
4 | import { Action, Dispatch } from 'redux';
5 | import { beginAjaxCall } from './ajaxStatusActions';
6 | import { push } from 'react-router-redux';
7 | import { success as successDialog } from 'react-notification-system-redux';
8 |
9 | const loadCoursesSuccess = (courses: Course[]) =>
10 | ({type: types.LoadCoursesSuccess, courses});
11 |
12 | export const updateCourseSuccess = (course: Course) =>
13 | ({type: types.UpdateCourseSuccess, course});
14 |
15 | export const createCourseSuccess = (course: Course) =>
16 | ({type: types.CreateCourseSuccess, course});
17 |
18 | export const loadCourses = () => (dispatch: Dispatch) => {
19 | dispatch(beginAjaxCall());
20 |
21 | return courseApi.getAllCourses()
22 | .then((courses: Course[]) => dispatch(loadCoursesSuccess(courses)))
23 | .catch(error => {
24 | throw(error);
25 | });
26 | };
27 |
28 | export const saveCourse = (course: Course) => async (dispatch: Dispatch) => {
29 | dispatch(beginAjaxCall());
30 |
31 | const savedCourse = await courseApi.saveCourse(course);
32 |
33 | if (course.id) {
34 | dispatch(updateCourseSuccess(savedCourse));
35 | dispatch(successDialog({
36 | title: 'Course saved',
37 | message: 'The course has been updated.',
38 | }));
39 | } else {
40 | dispatch(createCourseSuccess(savedCourse));
41 | dispatch(successDialog({
42 | title: 'Course saved',
43 | message: 'The course has been created.',
44 | }));
45 | }
46 | dispatch(push('/courses'));
47 | // .catch(error => {
48 | // dispatch(ajaxCallError());
49 | // dispatch(errorDialog({
50 | // title: 'Course save error',
51 | // message: 'Error: ' + error,
52 | // }));
53 | // });
54 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@biko/react-scripts": "0.4.0",
7 | "@types/enzyme": "^3.1.9",
8 | "react": "^16.2.0",
9 | "react-dom": "^16.2.0",
10 | "react-notification-system": "^0.2.17",
11 | "react-notification-system-redux": "^1.2.0",
12 | "react-redux": "^5.0.7",
13 | "react-router": "^4.2.0",
14 | "react-router-dom": "^4.2.2",
15 | "react-router-redux": "^5.0.0-alpha.9",
16 | "redux": "^3.7.2",
17 | "redux-thunk": "^2.2.0"
18 | },
19 | "scripts": {
20 | "start": "react-scripts start",
21 | "build": "react-scripts build",
22 | "test": "react-scripts test --env=jsdom",
23 | "eject": "react-scripts eject",
24 | "storybook": "start-storybook -p 6006",
25 | "build-storybook": "build-storybook"
26 | },
27 | "devDependencies": {
28 | "@types/enzyme-adapter-react-16": "^1.0.2",
29 | "@types/jest": "^22.1.3",
30 | "@types/nock": "^9.1.2",
31 | "@types/node": "^9.4.6",
32 | "@types/react": "^16.0.38",
33 | "@types/react-dom": "^16.0.4",
34 | "@types/react-notification-system": "^0.2.36",
35 | "@types/react-notification-system-redux": "^1.1.5",
36 | "@types/react-redux": "^5.0.15",
37 | "@types/react-router": "^4.0.22",
38 | "@types/react-router-dom": "^4.2.4",
39 | "@types/react-router-redux": "^5.0.12",
40 | "@types/react-test-renderer": "^16.0.1",
41 | "@types/redux-devtools-extension": "^2.13.2",
42 | "@types/redux-mock-store": "^0.0.13",
43 | "@types/redux-thunk": "^2.1.0",
44 | "enzyme": "^3.3.0",
45 | "enzyme-adapter-react-16": "^1.1.1",
46 | "jest-enzyme": "^4.2.0",
47 | "nock": "^9.2.3",
48 | "react-test-renderer": "^16.2.0",
49 | "redux-devtools-extension": "^2.13.2",
50 | "redux-mock-store": "~1.4.0",
51 | "typescript": "^2.7.2",
52 | "@storybook/react": "^3.3.14",
53 | "@storybook/addon-actions": "^3.3.14",
54 | "@storybook/addon-links": "^3.3.14",
55 | "@storybook/addons": "^3.3.14",
56 | "babel-core": "^6.26.0"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/course/CourseForm.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { FormEvent } from 'react';
3 | import { Course } from '../../redux/reducers/courseModel';
4 | import TextInput from '../common/TextInput';
5 | import SelectInput, { Option } from '../common/SelectInput';
6 | import { Errors } from './ManageCoursePage';
7 |
8 | type Props = Readonly<{
9 | course: Course;
10 | allAuthors: Array