├── 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 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {courses.map(course => ())} 23 | 24 |
 TitleAuthorCategoryLength
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