├── .gitignore ├── README-template.md ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── app │ ├── App.test.tsx │ └── App.tsx ├── character │ ├── Character.test.tsx │ ├── Character.tsx │ ├── CharacterContainer.test.tsx │ ├── CharacterContainer.tsx │ ├── CharacterList.test.tsx │ ├── CharacterList.tsx │ ├── CharacterListItem.test.tsx │ ├── CharacterListItem.tsx │ ├── CharacterMissing.test.tsx │ ├── CharacterMissing.tsx │ ├── CharacterSearch.test.tsx │ ├── CharacterSearch.tsx │ ├── Loader.test.tsx │ ├── Loader.tsx │ ├── NavigationBar.test.tsx │ ├── NavigationBar.tsx │ ├── actions │ │ ├── CharacterActionCreators.test.ts │ │ ├── CharacterActionCreators.ts │ │ ├── CharacterActionTypes.enum.ts │ │ ├── CharacterActions.type.ts │ │ └── IGetCharactersActions.interface.ts │ ├── data │ │ ├── Api.test.ts │ │ ├── Api.ts │ │ ├── GetCharacterMock.ts │ │ ├── GetCharactersMock.ts │ │ ├── ICharacter.interface.ts │ │ └── ICharacterState.interface.ts │ ├── reducers │ │ ├── CharacterReducer.test.ts │ │ └── CharacterReducer.ts │ └── sagas │ │ ├── Character.test.ts │ │ └── Character.ts ├── index.tsx ├── react-app-env.d.ts ├── root │ ├── Root.test.tsx │ └── Root.tsx ├── setupTests.ts └── store │ ├── IAppState.interface.ts │ ├── Store.test.ts │ └── Store.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /README-template.md: -------------------------------------------------------------------------------- 1 | This project demonstrates my changes starting from [Create React App](https://github.com/facebook/create-react-app) to get to a more production worthy app. 2 | 3 | I also updated the initial app to be based off of an article by [DefinedByChoice](https://twitter.com/DefinedByChoice/) that makes an API call for Star Wars characters and lists the characters. This change introduces a real world API call for populating the store, something closer to a real-world business app. 4 | 5 | 6 | | Statements | Branches | Functions | Lines | 7 | | -----------|----------|-----------|-------| 8 | | ![Statements](#statements# "Make me better!") | ![Branches](#branches# "Make me better!") | ![Functions](#functions# "Make me better!") | ![Lines](#lines# "Make me better!") | 9 | 10 | ## Goals 11 | 12 | - Add testing, test coverage reporting, and approach 100% test coverage. 13 | - Use a scalable file structure, centered around feature/domain. 14 | - Maintain small files and classes. 15 | - Leverage TypeScript's type system as much as possible. 16 | 17 | 18 | ## Added Patterns and Tools 19 | 20 | ### Code organization 21 | - [Thunk](https://github.com/reduxjs/redux-thunk): write action creators that return functions. 22 | - [Organize files around feature/domain](https://marmelab.com/blog/2015/12/17/react-directory-structure.html) 23 | 24 | ### Testing 25 | - Added [airbnb's Enzyme](https://github.com/airbnb/enzyme) for component testing 26 | - Added test coverage (`$ yarn test:coverage`) using the [Facebook docs](https://facebook.github.io/create-react-app/docs/running-tests) 27 | - Added test fixture for returned characters inside getCharactersMock.ts 28 | - I tried both container testing approaches here, and preferred the one that doesn't require connect. [here](https://hackernoon.com/unit-testing-redux-connected-components-692fa3c4441c) 29 | 30 | ## Available Scripts 31 | 32 | In the project directory, you can run: 33 | 34 | ### `yarn start` 35 | 36 | Runs the app in the development mode.
37 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 38 | 39 | The page will reload if you make edits.
40 | You will also see any lint errors in the console. 41 | 42 | ### `yarn test` 43 | 44 | Launches the test runner in the interactive watch mode.
45 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 46 | 47 | ### `yarn test:coverage` 48 | 49 | Launches the test runner in the interactive watch mode.
50 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 51 | 52 | ### `yarn run build` 53 | 54 | Builds the app for production to the `build` folder.
55 | It correctly bundles React in production mode and optimizes the build for the best performance. 56 | 57 | The build is minified and the filenames include the hashes.
58 | Your app is ready to be deployed! 59 | 60 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 61 | 62 | ### `yarn run eject` 63 | 64 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 65 | 66 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 67 | 68 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 69 | 70 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 71 | 72 | ## Learn More 73 | 74 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 75 | 76 | To learn React, check out the [React documentation](https://reactjs.org/). 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project demonstrates my changes starting from [Create React App](https://github.com/facebook/create-react-app) to get to a more production worthy app. 2 | 3 | I also updated the initial app to be based off of an article by [DefinedByChoice](https://twitter.com/DefinedByChoice/) that makes an API call for Star Wars characters and lists the characters. This change introduces a real world API call for populating the store, something closer to a real-world business app. 4 | 5 | 6 | | Statements | Branches | Functions | Lines | 7 | | -----------|----------|-----------|-------| 8 | | ![Statements](https://img.shields.io/badge/Coverage-88.24%25-yellow.svg "Make me better!") | ![Branches](https://img.shields.io/badge/Coverage-85.71%25-yellow.svg "Make me better!") | ![Functions](https://img.shields.io/badge/Coverage-78.13%25-red.svg "Make me better!") | ![Lines](https://img.shields.io/badge/Coverage-89.29%25-yellow.svg "Make me better!") | 9 | 10 | ## Goals 11 | 12 | - Add testing, test coverage reporting, and approach 100% test coverage. 13 | - Use a scalable file structure, centered around feature/domain. 14 | - Maintain small files and classes. 15 | - Leverage TypeScript's type system as much as possible. 16 | 17 | 18 | ## Added Patterns and Tools 19 | 20 | ### Code organization 21 | - [Thunk](https://github.com/reduxjs/redux-thunk): write action creators that return functions. 22 | - [Organize files around feature/domain](https://marmelab.com/blog/2015/12/17/react-directory-structure.html) 23 | 24 | ### Testing 25 | - Added [airbnb's Enzyme](https://github.com/airbnb/enzyme) for component testing 26 | - Added test coverage (`$ yarn test:coverage`) using the [Facebook docs](https://facebook.github.io/create-react-app/docs/running-tests) 27 | - Added test fixture for returned characters inside getCharactersMock.ts 28 | - I tried both container testing approaches here, and preferred the one that doesn't require connect. [here](https://hackernoon.com/unit-testing-redux-connected-components-692fa3c4441c) 29 | 30 | ## Available Scripts 31 | 32 | In the project directory, you can run: 33 | 34 | ### `yarn start` 35 | 36 | Runs the app in the development mode.
37 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 38 | 39 | The page will reload if you make edits.
40 | You will also see any lint errors in the console. 41 | 42 | ### `yarn test` 43 | 44 | Launches the test runner in the interactive watch mode.
45 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 46 | 47 | ### `yarn test:coverage` 48 | 49 | Launches the test runner in the interactive watch mode.
50 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 51 | 52 | ### `yarn run build` 53 | 54 | Builds the app for production to the `build` folder.
55 | It correctly bundles React in production mode and optimizes the build for the best performance. 56 | 57 | The build is minified and the filenames include the hashes.
58 | Your app is ready to be deployed! 59 | 60 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 61 | 62 | ### `yarn run eject` 63 | 64 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 65 | 66 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 67 | 68 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 69 | 70 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 71 | 72 | ## Learn More 73 | 74 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 75 | 76 | To learn React, check out the [React documentation](https://reactjs.org/). 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-typescript-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@olavoparno/jest-badges-readme": "^1.3.6", 7 | "@types/enzyme": "^3.10.3", 8 | "@types/enzyme-adapter-react-16": "^1.0.5", 9 | "@types/jest": "^24.0.15", 10 | "@types/node": "^12.6.8", 11 | "@types/react": "^16.8.23", 12 | "@types/react-dom": "^16.8.5", 13 | "@types/react-redux": "^7.1.1", 14 | "axios": "^0.19.0", 15 | "bootstrap": "4.3.1", 16 | "enzyme": "^3.10.0", 17 | "enzyme-adapter-react-16": "^1.14.0", 18 | "enzyme-to-json": "^3.3.5", 19 | "react": "^16.8.6", 20 | "react-dom": "^16.8.6", 21 | "react-redux": "^7.1.0", 22 | "react-scripts": "3.0.1", 23 | "react-test-renderer": "^16.8.6", 24 | "redux": "^4.0.4", 25 | "redux-devtools-extension": "^2.13.8", 26 | "redux-saga": "^1.1.3", 27 | "typescript": "^3.5.3" 28 | }, 29 | "scripts": { 30 | "start": "react-scripts start", 31 | "build": "react-scripts build", 32 | "test": "react-scripts test", 33 | "test:coverage": "react-scripts test --watchAll --coverage", 34 | "test:badges": "CI=true react-scripts test --coverage && jest-badges-readme", 35 | "eject": "react-scripts eject" 36 | }, 37 | "eslintConfig": { 38 | "extends": "react-app" 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "jest": { 53 | "collectCoverageFrom": [ 54 | "src/**/*.{js,jsx,ts,tsx}", 55 | "!/node_modules/", 56 | "!src/index.tsx" 57 | ], 58 | "coverageThreshold": { 59 | "global": { 60 | "branches": 80, 61 | "functions": 75, 62 | "lines": 80, 63 | "statements": 80 64 | } 65 | }, 66 | "coverageReporters": [ 67 | "json-summary", 68 | "text" 69 | ], 70 | "snapshotSerializers": [ 71 | "enzyme-to-json/serializer" 72 | ] 73 | }, 74 | "devDependencies": { 75 | "@types/moxios": "^0.4.8", 76 | "@types/redux-mock-store": "^1.0.1", 77 | "moxios": "^0.4.0", 78 | "redux-mock-store": "^1.5.3" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draffauf/react-redux-typescript-demo/2a36a96543bb383703cdb16f80cf36958ed174bd/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /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": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/app/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | // Business domain imports 5 | import App from './App'; 6 | import CharacterContainer from '../character/CharacterContainer'; 7 | 8 | describe('App', () => { 9 | const wrapper = shallow(); 10 | 11 | describe('renders', () => { 12 | it('CharacterContainer', () => { 13 | const element = ; 14 | expect(wrapper.contains(element)).toEqual(true); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // Business domain imports 4 | import CharacterContainer from '../character/CharacterContainer'; 5 | 6 | const App: React.SFC<{}> = () => ; 7 | 8 | export default App; 9 | -------------------------------------------------------------------------------- /src/character/Character.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import ICharacter from './data/ICharacter.interface'; 5 | import GetCharacterMock from './data/GetCharacterMock'; 6 | import Character from './Character'; 7 | 8 | describe('Character', () => { 9 | const character: ICharacter = GetCharacterMock; 10 | const wrapper = mount(); 11 | 12 | describe('renders', () => { 13 | it('a list item', () => { 14 | const character = wrapper.find('li') 15 | expect(character).toBeDefined(); 16 | }); 17 | 18 | it('handles submission', () => { 19 | const heading = wrapper.find('h2'); 20 | expect(heading.text()).toEqual(character.name); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/character/Character.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import ICharacter from './data/ICharacter.interface'; 4 | 5 | interface IProps { 6 | character: ICharacter 7 | } 8 | 9 | const Character: React.FunctionComponent = ({ character }: IProps) => ( 10 | <> 11 |

{character.name}

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
InfoValue
Height{character.height}
Mass{character.mass}
31 | 32 | ) 33 | 34 | export default Character; 35 | -------------------------------------------------------------------------------- /src/character/CharacterContainer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, ShallowWrapper } from 'enzyme'; 3 | 4 | // Business domain imports 5 | import GetCharacterMock from './data/GetCharactersMock'; 6 | import GetCharactersMock from './data/GetCharactersMock'; 7 | import { CharacterContainer } from './CharacterContainer'; 8 | import ICharacter from './data/ICharacter.interface'; 9 | import Loader from './Loader'; 10 | 11 | // Extract to helper? 12 | interface renderElementParameters { 13 | getCharacters: jest.Mock, 14 | searchCharacters: jest.Mock, 15 | setCharacter: jest.Mock, 16 | character: ICharacter, 17 | characters: ICharacter[], 18 | isFetching: Boolean, 19 | } 20 | 21 | const defaultProps:renderElementParameters = { 22 | getCharacters: jest.fn(), 23 | setCharacter: jest.fn(), 24 | searchCharacters: jest.fn(), 25 | characters: [], 26 | character: GetCharacterMock, 27 | isFetching: false, 28 | } 29 | 30 | const renderCharacterListContainer = (overrides: any): ShallowWrapper => { 31 | return shallow( 32 | 36 | ); 37 | } 38 | 39 | // Workaround for Enyzme testing of useEffect 40 | // See: https://blog.carbonfive.com/2019/08/05/shallow-testing-hooks-with-enzyme/ 41 | const mockUseEffect = (): jest.SpyInstance => { 42 | return jest.spyOn(React, 'useEffect').mockImplementation(f => f()); 43 | } 44 | 45 | // Tests 46 | describe('CharacterListContainer', () => { 47 | describe('when fetching', () => { 48 | const wrapper = renderCharacterListContainer({ isFetching: true }); 49 | 50 | it('display "Loader"', () => { 51 | const element = ; 52 | 53 | expect(wrapper.contains(element)).toBe(true); 54 | }); 55 | }); 56 | 57 | describe('on initial render', () => { 58 | const characters: ICharacter[] = []; 59 | const getCharacters = jest.fn().mockResolvedValue(GetCharactersMock); 60 | mockUseEffect(); 61 | const wrapper = renderCharacterListContainer({ characters, getCharacters }); 62 | 63 | it('calls getCharacters', () => { 64 | expect(getCharacters).toHaveBeenCalledTimes(1); 65 | }); 66 | 67 | it('a character container', () => { 68 | expect(wrapper.find('div.characters-container')).toHaveLength(1); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/character/CharacterContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import IAppState from '../store/IAppState.interface'; 5 | import ICharacter from './data/ICharacter.interface'; 6 | 7 | import { 8 | setCharacterActionCreator, 9 | getCharactersStartActionCreator, 10 | searchCharactersActionCreator 11 | } from './actions/CharacterActionCreators'; 12 | 13 | import Character from './Character'; 14 | import CharacterList from './CharacterList'; 15 | import CharacterMissing from './CharacterMissing'; 16 | import CharacterSearch from './CharacterSearch'; 17 | import Loader from './Loader'; 18 | import NavigationBar from './NavigationBar'; 19 | 20 | interface IProps { 21 | getCharacters: Function, 22 | setCharacter: Function, 23 | searchCharacters: Function, 24 | character: any, 25 | characters: ICharacter[], 26 | isFetching: Boolean 27 | } 28 | 29 | // Note: This is mainly done to enable testing 30 | export const CharacterContainer: React.FunctionComponent = ({ 31 | getCharacters, 32 | setCharacter, 33 | searchCharacters, 34 | character, 35 | characters, 36 | isFetching 37 | }) => { 38 | // Workaround for Enyzme testing of useEffect, allows stubbing 39 | // See: https://blog.carbonfive.com/2019/08/05/shallow-testing-hooks-with-enzyme/ 40 | React.useEffect(() => { 41 | getCharacters(); 42 | }, [getCharacters]); 43 | 44 | return ( 45 |
46 | 47 | 48 | 49 | 50 | { isFetching 51 | ? 52 | : ( 53 |
54 |
55 | 58 |
59 | 60 |
61 | {character 62 | ? 63 | : } 64 |
65 |
66 | ) 67 | } 68 |
69 | ); 70 | } 71 | 72 | // Make data available on props 73 | const mapStateToProps = (store: IAppState) => { 74 | return { 75 | character: store.characterState.character, 76 | characters: store.characterState.characters, 77 | isFetching: store.characterState.isFetching, 78 | }; 79 | }; 80 | 81 | // Make functions available on props 82 | const mapDispatchToProps = (dispatch: any) => { 83 | return { 84 | getCharacters: () => dispatch(getCharactersStartActionCreator()), 85 | setCharacter: (character: any) => dispatch(setCharacterActionCreator(character)), 86 | searchCharacters: (term: string) => dispatch(searchCharactersActionCreator(term)), 87 | } 88 | } 89 | 90 | // Connect the app aware container to the store and reducers 91 | export default connect(mapStateToProps, mapDispatchToProps)(CharacterContainer); 92 | -------------------------------------------------------------------------------- /src/character/CharacterList.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | // Business domain imports 5 | import ICharacter from './data/ICharacter.interface'; 6 | import GetCharactersMock from './data/GetCharactersMock'; 7 | import CharacterList from './CharacterList'; 8 | 9 | describe('CharacterList', () => { 10 | const setCharacter = jest.fn(); 11 | 12 | describe('without characters', () => { 13 | const characters: ICharacter[] = []; 14 | const wrapper = shallow(); 15 | 16 | describe('renders', () => { 17 | it('empty undordered list', () => { 18 | const element =
    ; 19 | expect(wrapper.contains(element)).toEqual(true); 20 | }); 21 | }); 22 | }); 23 | 24 | describe('with characters', () => { 25 | const characters: ICharacter[] = GetCharactersMock; 26 | const wrapper = shallow(); 27 | const character: ICharacter = characters[0]; 28 | 29 | describe('renders', () => { 30 | it('a list item per character', () => { 31 | const character = wrapper.find('li'); 32 | expect(character).toBeDefined(); 33 | }); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/character/CharacterList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // Business domain imports 4 | import ICharacter from './data/ICharacter.interface'; 5 | import CharacterListItem from './CharacterListItem'; 6 | 7 | interface IProps { 8 | setCharacter: Function, 9 | characters: ICharacter[]; 10 | } 11 | 12 | const CharacterList: React.FunctionComponent = ({ characters, setCharacter }) => ( 13 |
      14 | {characters && characters.map(character => ( 15 | 19 | ))} 20 |
    21 | ); 22 | 23 | export default CharacterList; 24 | -------------------------------------------------------------------------------- /src/character/CharacterListItem.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import ICharacter from './data/ICharacter.interface'; 5 | import GetCharacterMock from './data/GetCharacterMock'; 6 | import CharacterListItem from './CharacterListItem'; 7 | 8 | describe('CharacterListItem', () => { 9 | const setCharacter = jest.fn(); 10 | const character: ICharacter = GetCharacterMock; 11 | const wrapper = mount(); 12 | 13 | describe('renders', () => { 14 | it('a list item', () => { 15 | const character = wrapper.find('li') 16 | expect(character).toBeDefined(); 17 | }); 18 | 19 | it('handles submission', () => { 20 | const character = wrapper.find('li'); 21 | character.simulate('click'); 22 | expect(setCharacter).toHaveBeenCalledTimes(1); 23 | }); 24 | 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/character/CharacterListItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import ICharacter from './data/ICharacter.interface'; 4 | 5 | interface IProps { 6 | character: ICharacter, 7 | setCharacter: Function, 8 | } 9 | 10 | const CharacterListItem: React.FunctionComponent = ({ character, setCharacter }: IProps) => { 11 | const onClickHandler = (event: React.MouseEvent) => { 12 | event.preventDefault(); 13 | setCharacter(character); 14 | } 15 | 16 | return ( 17 |
  • 21 | {character.name} 22 |
  • 23 | ); 24 | }; 25 | 26 | export default CharacterListItem; 27 | -------------------------------------------------------------------------------- /src/character/CharacterMissing.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import CharacterMissing from './CharacterMissing'; 5 | 6 | describe('CharacterMissing', () => { 7 | const wrapper = mount(); 8 | 9 | describe('renders', () => { 10 | it('a heading', () => { 11 | const character = wrapper.find('h2') 12 | expect(character.text()).toEqual('Select a Character'); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/character/CharacterMissing.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const CharacterMissing: React.FunctionComponent<{}> = () =>

    Select a Character

    4 | 5 | export default CharacterMissing; 6 | -------------------------------------------------------------------------------- /src/character/CharacterSearch.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import CharacterSearch from './CharacterSearch'; 5 | 6 | describe('CharacterSearch', () => { 7 | const searchCharacters = jest.fn(); 8 | const wrapper = mount(); 9 | 10 | describe('renders', () => { 11 | it('a form', () => { 12 | const form = wrapper.find('form'); 13 | expect(form).toBeDefined(); 14 | }); 15 | 16 | it('handles submission', () => { 17 | const form = wrapper.find('form'); 18 | const input = wrapper.find('input'); 19 | const newValue = 'Ch-ch-changes'; 20 | input.simulate('change', { target: { value: newValue } }) 21 | form.simulate('submit'); 22 | expect(searchCharacters).toHaveBeenCalledWith(newValue); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/character/CharacterSearch.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | // Create interface for Props 4 | interface IProps { 5 | searchCharacters: Function, 6 | } 7 | 8 | const CharacterSearch: React.FunctionComponent = ({ searchCharacters }: IProps) => { 9 | const [searchTerm, setSearchTerm] = useState(''); 10 | 11 | const onChangeHandler = (event: React.ChangeEvent) => { 12 | const input = (event.target as HTMLInputElement).value; 13 | setSearchTerm(input); 14 | } 15 | 16 | const onSubmitHandler = (event: React.FormEvent) => { 17 | event.preventDefault(); 18 | searchCharacters(searchTerm); 19 | } 20 | 21 | return ( 22 |
    25 | 32 | 36 |
    37 | ); 38 | }; 39 | 40 | export default CharacterSearch; 41 | -------------------------------------------------------------------------------- /src/character/Loader.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Loader from './Loader'; 5 | 6 | describe('CharacterListItem', () => { 7 | const wrapper = shallow(); 8 | 9 | describe('renders', () => { 10 | it('loading text', () => { 11 | expect(wrapper.contains('Loading')).toBe(true); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/character/Loader.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface IProps {} 4 | 5 | const Loader: React.FunctionComponent = () => ( 6 |
    7 |
    Loading
    15 |
    16 | ); 17 | 18 | export default Loader; 19 | -------------------------------------------------------------------------------- /src/character/NavigationBar.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import NavigationBar from './NavigationBar'; 5 | 6 | describe('NavigationBar', () => { 7 | const wrapper = shallow( 8 | 9 |

    Yeah!

    10 |
    11 | ); 12 | 13 | describe('renders', () => { 14 | it('loading text', () => { 15 | expect(wrapper.contains('Star Wars Characters')).toBe(true); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/character/NavigationBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | 3 | interface IProps { 4 | children: ReactNode, 5 | } 6 | 7 | const NavigationBar: React.FunctionComponent = ({ children }) => ( 8 | 12 | ); 13 | 14 | export default NavigationBar; 15 | -------------------------------------------------------------------------------- /src/character/actions/CharacterActionCreators.test.ts: -------------------------------------------------------------------------------- 1 | // App imports 2 | import GetCharacterMock from '../data/GetCharacterMock'; 3 | import GetCharactersMock from '../data/GetCharactersMock'; 4 | import { 5 | setCharacterActionCreator, 6 | searchCharactersActionCreator, 7 | getCharactersStartActionCreator, 8 | getCharactersSuccessActionCreator, 9 | getCharactersFailureActionCreator, 10 | } from './CharacterActionCreators'; 11 | import CharacterActionTypes from './CharacterActionTypes.enum'; 12 | 13 | // Tests 14 | describe('setCharacter', () => { 15 | it('creates ISetCharacterAction', () => { 16 | const action = setCharacterActionCreator(GetCharacterMock); 17 | 18 | expect(action).toEqual({ 19 | type: CharacterActionTypes.SET_CHARACTER, 20 | character: GetCharacterMock, 21 | isFetching: false, 22 | }); 23 | }); 24 | }); 25 | 26 | describe('searchCharacters', () => { 27 | it('creates ISearchCharactersAction', () => { 28 | const term = "Darth"; 29 | const action = searchCharactersActionCreator(term); 30 | 31 | expect(action).toEqual({ 32 | type: CharacterActionTypes.SEARCH_CHARACTERS, 33 | term, 34 | isFetching: true, 35 | }); 36 | }); 37 | }); 38 | 39 | describe('getCharactersStart', () => { 40 | it('creates IGetCharactersStartAction', () => { 41 | const action = getCharactersStartActionCreator(); 42 | 43 | expect(action).toEqual({ 44 | type: CharacterActionTypes.GET_CHARACTERS_START, 45 | isFetching: true, 46 | }); 47 | }); 48 | }); 49 | 50 | describe('getCharactersSuccess', () => { 51 | it('creates IGetCharactersSuccessAction', () => { 52 | const action = getCharactersSuccessActionCreator(GetCharactersMock); 53 | 54 | expect(action).toEqual({ 55 | type: CharacterActionTypes.GET_CHARACTERS_SUCCESS, 56 | characters: GetCharactersMock, 57 | isFetching: false, 58 | }); 59 | }); 60 | }); 61 | 62 | describe('getCharactersFailure', () => { 63 | it('creates IGetCharactersFailureAction', () => { 64 | const action = getCharactersFailureActionCreator(); 65 | 66 | expect(action).toEqual({ 67 | type: CharacterActionTypes.GET_CHARACTERS_FAILURE, 68 | isFetching: false, 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/character/actions/CharacterActionCreators.ts: -------------------------------------------------------------------------------- 1 | // Business domain imports 2 | import CharacterActionTypes from './CharacterActionTypes.enum'; 3 | import { 4 | ISetCharacterAction, 5 | IGetCharactersStartAction, 6 | IGetCharactersSuccessAction, 7 | IGetCharactersFailureAction, 8 | ISearchCharactersAction 9 | } from './IGetCharactersActions.interface'; 10 | import ICharacter from '../data/ICharacter.interface'; 11 | 12 | export const setCharacterActionCreator = (character: ICharacter): ISetCharacterAction => { 13 | return { 14 | type: CharacterActionTypes.SET_CHARACTER, 15 | character: character, 16 | isFetching: false, 17 | }; 18 | } 19 | 20 | export const searchCharactersActionCreator = (term: string): ISearchCharactersAction => { 21 | return { 22 | type: CharacterActionTypes.SEARCH_CHARACTERS, 23 | term, 24 | isFetching: true, 25 | }; 26 | } 27 | 28 | export const getCharactersStartActionCreator = (): IGetCharactersStartAction => { 29 | return { 30 | type: CharacterActionTypes.GET_CHARACTERS_START, 31 | isFetching: true, 32 | }; 33 | } 34 | 35 | export const getCharactersSuccessActionCreator = (characters: ICharacter[]): IGetCharactersSuccessAction => { 36 | return { 37 | type: CharacterActionTypes.GET_CHARACTERS_SUCCESS, 38 | characters, 39 | isFetching: false, 40 | }; 41 | } 42 | 43 | export const getCharactersFailureActionCreator = (): IGetCharactersFailureAction => { 44 | return { 45 | type: CharacterActionTypes.GET_CHARACTERS_FAILURE, 46 | isFetching: false, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/character/actions/CharacterActionTypes.enum.ts: -------------------------------------------------------------------------------- 1 | enum CharacterActionTypes { 2 | SET_CHARACTER = 'SET_CHARACTER', 3 | SEARCH_CHARACTERS = 'SEARCH_CHARACTERS', 4 | GET_CHARACTERS_START = 'GET_CHARACTERS_START', 5 | GET_CHARACTERS_SUCCESS = 'GET_CHARACTERS_SUCCESS', 6 | GET_CHARACTERS_FAILURE = 'GET_CHARACTERS_FAILURE' 7 | } 8 | 9 | export default CharacterActionTypes; 10 | -------------------------------------------------------------------------------- /src/character/actions/CharacterActions.type.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ISetCharacterAction, 3 | ISearchCharactersAction, 4 | IGetCharactersStartAction, 5 | IGetCharactersSuccessAction, 6 | IGetCharactersFailureAction 7 | } from './IGetCharactersActions.interface'; 8 | 9 | // Combine the action types with a union (we assume there are more) 10 | type CharacterActions = 11 | ISetCharacterAction 12 | | ISearchCharactersAction 13 | | IGetCharactersStartAction 14 | | IGetCharactersSuccessAction 15 | | IGetCharactersFailureAction; 16 | 17 | export default CharacterActions; 18 | -------------------------------------------------------------------------------- /src/character/actions/IGetCharactersActions.interface.ts: -------------------------------------------------------------------------------- 1 | import ICharacter from '../data/ICharacter.interface'; 2 | import CharacterActionTypes from './CharacterActionTypes.enum'; 3 | 4 | export interface ISetCharacterAction { 5 | type: CharacterActionTypes.SET_CHARACTER, 6 | character: ICharacter, 7 | isFetching: false, 8 | } 9 | 10 | export interface ISearchCharactersAction { 11 | type: CharacterActionTypes.SEARCH_CHARACTERS, 12 | term: string, 13 | isFetching: true, 14 | } 15 | 16 | export interface IGetCharactersStartAction { 17 | type: CharacterActionTypes.GET_CHARACTERS_START, 18 | isFetching: true, 19 | } 20 | export interface IGetCharactersSuccessAction { 21 | type: CharacterActionTypes.GET_CHARACTERS_SUCCESS, 22 | characters: ICharacter[], 23 | isFetching: false, 24 | } 25 | export interface IGetCharactersFailureAction { 26 | type: CharacterActionTypes.GET_CHARACTERS_FAILURE, 27 | isFetching: false, 28 | } 29 | -------------------------------------------------------------------------------- /src/character/data/Api.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { 3 | getCharactersFromApi, 4 | searchCharactersFromApi, 5 | } from './Api'; 6 | 7 | // Tests 8 | describe('getCharacters', () => { 9 | beforeEach(() => { 10 | axios.get = jest.fn(); 11 | }) 12 | 13 | describe('getCharacters', () => { 14 | it('httpClient is called as expected', () => { 15 | getCharactersFromApi(); 16 | expect(axios.get).toHaveBeenCalledWith('https://swapi.co/api/people/'); 17 | }); 18 | }); 19 | 20 | describe('searchCharacters', () => { 21 | const searchString = 'Luke'; 22 | 23 | it('httpClient is called as expected', () => { 24 | searchCharactersFromApi(searchString); 25 | expect(axios.get).toHaveBeenCalledWith(`https://swapi.co/api/people/?search=${searchString}`); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/character/data/Api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const baseUrl = 'https://swapi.co/api'; 4 | 5 | export const getCharactersFromApi = (): Promise => { 6 | return axios.get(`${baseUrl}/people/`); 7 | } 8 | 9 | export const searchCharactersFromApi = (term: String): Promise => { 10 | return axios.get(`${baseUrl}/people/?search=${term}`); 11 | } 12 | -------------------------------------------------------------------------------- /src/character/data/GetCharacterMock.ts: -------------------------------------------------------------------------------- 1 | import ICharacter from './ICharacter.interface'; 2 | 3 | const GetCharacterMock: ICharacter = { 4 | name: 'Luke Skywalker', 5 | height: '172', 6 | mass: '77', 7 | hair_color: 'blond', 8 | skin_color: 'fair', 9 | eye_color: 'blue', 10 | birth_year: '19BBY', 11 | gender: 'male', 12 | homeworld: 'https://swapi.co/api/planets/1/', 13 | films: [ 14 | 'https://swapi.co/api/films/2/', 15 | ], 16 | species: [ 17 | 'https://swapi.co/api/species/1/' 18 | ], 19 | vehicles: [ 20 | 'https://swapi.co/api/vehicles/14/', 21 | ], 22 | starships: [ 23 | 'https://swapi.co/api/starships/12/', 24 | ], 25 | created: '2014-12-09T13:50:51.644000Z', 26 | edited: '2014-12-20T21:17:56.891000Z', 27 | url: 'https://swapi.co/api/people/1/' 28 | } 29 | 30 | export default GetCharacterMock; 31 | -------------------------------------------------------------------------------- /src/character/data/GetCharactersMock.ts: -------------------------------------------------------------------------------- 1 | import ICharacter from './ICharacter.interface'; 2 | import GetCharacterMock from './GetCharacterMock'; 3 | 4 | const GetCharactersMock: ICharacter[] = [GetCharacterMock]; 5 | 6 | export default GetCharactersMock; 7 | -------------------------------------------------------------------------------- /src/character/data/ICharacter.interface.ts: -------------------------------------------------------------------------------- 1 | export default interface ICharacter { 2 | name: string; 3 | height: string; 4 | mass: string; 5 | hair_color: string; 6 | skin_color: string; 7 | eye_color: string; 8 | birth_year: string; 9 | gender: string; 10 | homeworld: string; 11 | films: string[]; 12 | species: string[]; 13 | vehicles: string[]; 14 | starships: string[]; 15 | created: string; 16 | edited: string; 17 | url: string; 18 | } -------------------------------------------------------------------------------- /src/character/data/ICharacterState.interface.ts: -------------------------------------------------------------------------------- 1 | import ICharacter from './ICharacter.interface'; 2 | 3 | export default interface ICharacterState { 4 | readonly character?: ICharacter, 5 | readonly characters: ICharacter[], 6 | readonly isFetching: Boolean, 7 | } 8 | -------------------------------------------------------------------------------- /src/character/reducers/CharacterReducer.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | setCharacterActionCreator, 3 | getCharactersStartActionCreator, 4 | getCharactersSuccessActionCreator, 5 | getCharactersFailureActionCreator 6 | } from '../actions/CharacterActionCreators'; 7 | import ICharacterState from "../data/ICharacterState.interface"; 8 | import GetCharacterMock from '../data/GetCharacterMock'; 9 | import GetCharactersMock from '../data/GetCharactersMock'; 10 | import CharacterReducer from './CharacterReducer'; 11 | 12 | const initialState: ICharacterState = { 13 | characters: [], 14 | isFetching: false, 15 | }; 16 | 17 | describe('CharacterReducer action type responses for', () => { 18 | describe('SET_CHARACTER', () => { 19 | const action = setCharacterActionCreator(GetCharacterMock); 20 | const newState = CharacterReducer(initialState, action); 21 | 22 | it('character is set', () => { 23 | expect(newState.character).toEqual(GetCharacterMock); 24 | }); 25 | }); 26 | 27 | describe('GET_CHARACTERS_START', () => { 28 | const action = getCharactersStartActionCreator(); 29 | const newState = CharacterReducer(initialState, action); 30 | 31 | it('is fetching', () => { 32 | expect(newState.isFetching).toBe(true); 33 | }); 34 | }); 35 | 36 | describe('GET_CHARACTERS_SUCCESS', () => { 37 | const data = GetCharactersMock ; 38 | const action = getCharactersSuccessActionCreator(data); 39 | const newState = CharacterReducer(initialState, action); 40 | it('fetched characters', () => { 41 | expect(newState.characters).toEqual(GetCharactersMock); 42 | }); 43 | 44 | it('is not fetching', () => { 45 | expect(newState.isFetching).toBe(false); 46 | }); 47 | }); 48 | 49 | describe('GET_CHARACTERS_FAILURE', () => { 50 | const action = getCharactersFailureActionCreator(); 51 | const newState = CharacterReducer(initialState, action); 52 | 53 | it('has not fetched characters', () => { 54 | expect(newState.characters).toEqual([]); 55 | }); 56 | 57 | it('is not fetching', () => expect(newState.isFetching).toBe(false)); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/character/reducers/CharacterReducer.ts: -------------------------------------------------------------------------------- 1 | // Import Reducer type 2 | import { Reducer } from 'redux'; 3 | 4 | // Busines domain imports 5 | import CharacterActions from '../actions/CharacterActions.type'; 6 | import CharacterActionTypes from '../actions/CharacterActionTypes.enum'; 7 | import ICharacterState from '../data/ICharacterState.interface'; 8 | 9 | // Business logic 10 | const initialCharacterState: ICharacterState = { 11 | character: undefined, 12 | characters: [], 13 | isFetching: false, 14 | }; 15 | 16 | const CharacterReducer: Reducer = ( 17 | state = initialCharacterState, 18 | action 19 | ) => { 20 | switch (action.type) { 21 | case CharacterActionTypes.SET_CHARACTER: { 22 | return { 23 | ...state, 24 | character: action.character, 25 | }; 26 | } 27 | case CharacterActionTypes.SEARCH_CHARACTERS: { 28 | return { 29 | ...state, 30 | isFetching: action.isFetching, 31 | }; 32 | } 33 | case CharacterActionTypes.GET_CHARACTERS_START: { 34 | return { 35 | ...state, 36 | isFetching: action.isFetching, 37 | }; 38 | } 39 | case CharacterActionTypes.GET_CHARACTERS_SUCCESS: { 40 | return { 41 | ...state, 42 | characters: action.characters, 43 | isFetching: action.isFetching, 44 | }; 45 | } 46 | case CharacterActionTypes.GET_CHARACTERS_FAILURE: { 47 | return { 48 | ...state, 49 | isFetching: action.isFetching, 50 | }; 51 | } 52 | default: 53 | return state; 54 | } 55 | }; 56 | 57 | export default CharacterReducer; 58 | -------------------------------------------------------------------------------- /src/character/sagas/Character.test.ts: -------------------------------------------------------------------------------- 1 | // Libraries 2 | import { call, put } from 'redux-saga/effects'; 3 | 4 | // App imports 5 | import { 6 | searchCharactersActionCreator, 7 | getCharactersStartActionCreator, 8 | getCharactersSuccessActionCreator, 9 | getCharactersFailureActionCreator, 10 | } from '../actions/CharacterActionCreators'; 11 | 12 | import GetCharactersMock from '../data/GetCharactersMock'; 13 | 14 | import { 15 | getCharactersFromApi, 16 | searchCharactersFromApi, 17 | } from '../data/Api'; 18 | 19 | import { 20 | getCharactersSaga, 21 | searchCharactersSaga, 22 | } from './Character'; 23 | 24 | // Tests 25 | describe('getCharacters', () => { 26 | it('success triggers success action with characters', () => { 27 | const generator = getCharactersSaga(); 28 | const response = { data: { results: GetCharactersMock } }; 29 | 30 | expect(generator.next().value) 31 | .toEqual(call(getCharactersFromApi)); 32 | 33 | expect(generator.next(response).value) 34 | .toEqual(put(getCharactersSuccessActionCreator(GetCharactersMock))); 35 | 36 | expect(generator.next()) 37 | .toEqual({ done: true, value: undefined }); 38 | }); 39 | 40 | it('failure triggers failure action', () => { 41 | const generator = getCharactersSaga(); 42 | const response = {}; 43 | 44 | expect(generator.next().value) 45 | .toEqual(call(getCharactersFromApi)); 46 | 47 | expect(generator.next(response).value) 48 | .toEqual(put(getCharactersFailureActionCreator())); 49 | 50 | expect(generator.next()) 51 | .toEqual({ done: true, value: undefined }); 52 | }); 53 | }); 54 | 55 | describe('searchCharacters', () => { 56 | it('success triggers success action with characters', () => { 57 | const term = 'Luke'; 58 | const generator = searchCharactersSaga(searchCharactersActionCreator(term)); 59 | const response = { data: { results: GetCharactersMock } }; 60 | 61 | expect(generator.next().value) 62 | .toEqual(call(searchCharactersFromApi, term)); 63 | 64 | expect(generator.next(response).value) 65 | .toEqual(put(getCharactersSuccessActionCreator(GetCharactersMock))); 66 | 67 | expect(generator.next()) 68 | .toEqual({ done: true, value: undefined }); 69 | }); 70 | 71 | it('failure triggers failure action', () => { 72 | const term = 'Luke'; 73 | const generator = searchCharactersSaga(searchCharactersActionCreator(term)); 74 | const response = {}; 75 | 76 | expect(generator.next().value) 77 | .toEqual(call(searchCharactersFromApi, term)); 78 | 79 | expect(generator.next(response).value) 80 | .toEqual(put(getCharactersFailureActionCreator())); 81 | 82 | expect(generator.next()) 83 | .toEqual({ done: true, value: undefined }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/character/sagas/Character.ts: -------------------------------------------------------------------------------- 1 | import { call, put, takeEvery, all } from 'redux-saga/effects'; 2 | import CharacterActionTypes from '../actions/CharacterActionTypes.enum'; 3 | 4 | import { 5 | getCharactersFromApi, 6 | searchCharactersFromApi, 7 | } from '../data/Api'; 8 | 9 | import { 10 | getCharactersSuccessActionCreator, 11 | getCharactersFailureActionCreator 12 | } from '../actions/CharacterActionCreators'; 13 | 14 | import { 15 | ISearchCharactersAction 16 | } from '../actions/IGetCharactersActions.interface'; 17 | 18 | 19 | export function* getCharactersSaga() : any { 20 | try { 21 | const response = yield call(getCharactersFromApi); 22 | const characters = response.data.results; 23 | yield put(getCharactersSuccessActionCreator(characters)) 24 | } catch(e) { 25 | yield put(getCharactersFailureActionCreator()); 26 | } 27 | } 28 | 29 | export function* searchCharactersSaga(action: ISearchCharactersAction) : any { 30 | try { 31 | const response = yield call(searchCharactersFromApi, action.term); 32 | const characters = response.data.results; 33 | yield put(getCharactersSuccessActionCreator(characters)) 34 | } catch(e) { 35 | yield put(getCharactersFailureActionCreator()); 36 | } 37 | }; 38 | 39 | export function* charactersSaga() { 40 | yield all([ 41 | takeEvery(CharacterActionTypes.GET_CHARACTERS_START, getCharactersSaga), 42 | takeEvery(CharacterActionTypes.SEARCH_CHARACTERS, searchCharactersSaga) 43 | ]); 44 | } 45 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | // Business domain 5 | import configureStore from "./store/Store"; 6 | import Root from './root/Root'; 7 | import 'bootstrap/dist/css/bootstrap.css'; 8 | 9 | // Generate the store 10 | const store = configureStore(); 11 | 12 | // Render the App 13 | ReactDOM.render( 14 | , 15 | document.getElementById('root') as HTMLElement 16 | ); 17 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/root/Root.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import App from '../app/App'; 4 | import Root from './Root'; 5 | import configureStore from "../store/Store"; 6 | 7 | describe('Root', () => { 8 | const store = configureStore(); 9 | const wrapper = shallow(); 10 | 11 | describe('renders', () => { 12 | it('App', () => { 13 | const element = ; 14 | expect(wrapper.contains(element)).toEqual(true); 15 | }); 16 | }); 17 | 18 | describe('properties', () => { 19 | it('store', () => expect(wrapper.props().store).toEqual(store)); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/root/Root.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { Store } from 'redux'; 4 | 5 | // Business domain imports 6 | import App from '../app/App'; 7 | import IAppState from '../store/IAppState.interface'; 8 | 9 | // Create interface for Props 10 | interface IProps { 11 | store: Store; 12 | } 13 | 14 | // Create a root component that receives the store via props and 15 | // wraps the App component with Provider, giving props to containers 16 | const Root: React.SFC = ({ store }) => ( 17 | 18 | 19 | 20 | ); 21 | 22 | export default Root; 23 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /src/store/IAppState.interface.ts: -------------------------------------------------------------------------------- 1 | import ICharacterState from "../character/data/ICharacterState.interface"; 2 | 3 | export default interface IAppState { 4 | characterState: ICharacterState; 5 | } 6 | -------------------------------------------------------------------------------- /src/store/Store.test.ts: -------------------------------------------------------------------------------- 1 | import configureMockStore from 'redux-mock-store'; 2 | import moxios from 'moxios'; 3 | 4 | // App imports 5 | import createSagaMiddleware from 'redux-saga'; 6 | import { 7 | getCharactersStartActionCreator, 8 | getCharactersSuccessActionCreator, 9 | getCharactersFailureActionCreator, 10 | } from '../character/actions/CharacterActionCreators'; 11 | import GetCharactersMock from '../character/data/GetCharactersMock'; 12 | import { charactersSaga } from '../character/sagas/Character'; 13 | 14 | // Configure the mockStore function 15 | // Note: if this begins to be used in several places, make a helper 16 | const sagaMiddleware = createSagaMiddleware(); 17 | const mockStore = configureMockStore([sagaMiddleware]); 18 | 19 | // Tests 20 | describe('getCharactersStart', () => { 21 | beforeEach(() => { moxios.install(); }); 22 | afterEach(() => { moxios.uninstall(); }); 23 | 24 | it('creates GET_CHARACTERS_START, GET_CHARACTERS_SUCCESS after successfuly fetching characters', done => { 25 | moxios.wait(() => { 26 | const request = moxios.requests.mostRecent(); 27 | request.respondWith({ 28 | status: 200, 29 | response: { results: GetCharactersMock }, 30 | }); 31 | }); 32 | 33 | const expectedActions = [ 34 | getCharactersStartActionCreator(), 35 | getCharactersSuccessActionCreator(GetCharactersMock), 36 | ]; 37 | 38 | const initialState = { 39 | characters: [], 40 | isFetching: false, 41 | }; 42 | const store = mockStore(initialState); 43 | sagaMiddleware.run(charactersSaga); 44 | 45 | store.subscribe(() => { 46 | const actions = store.getActions(); 47 | if (actions.length >= expectedActions.length) { 48 | expect(actions).toEqual(expectedActions); 49 | done(); 50 | } 51 | }); 52 | 53 | store.dispatch(getCharactersStartActionCreator()); 54 | }); 55 | 56 | it('creates GET_CHARACTERS_START, GET_CHARACTERS_FAILURE after failing to fetch characters', done => { 57 | moxios.wait(() => { 58 | const request = moxios.requests.mostRecent(); 59 | request.respondWith({ 60 | status: 500, 61 | response: {}, 62 | }); 63 | }); 64 | 65 | const expectedActions = [ 66 | getCharactersStartActionCreator(), 67 | getCharactersFailureActionCreator(), 68 | ]; 69 | 70 | const initialState = { characters: [] }; 71 | const store = mockStore(initialState); 72 | sagaMiddleware.run(charactersSaga); 73 | 74 | store.subscribe(() => { 75 | const actions = store.getActions(); 76 | if (actions.length >= expectedActions.length) { 77 | expect(actions).toEqual(expectedActions); 78 | done(); 79 | } 80 | }); 81 | 82 | store.dispatch(getCharactersStartActionCreator()); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/store/Store.tsx: -------------------------------------------------------------------------------- 1 | // Third-Party dependencies 2 | import { 3 | applyMiddleware, 4 | combineReducers, 5 | createStore, 6 | Store 7 | } from 'redux'; 8 | 9 | // React Sagas 10 | import createSagaMiddleware from 'redux-saga'; 11 | 12 | // Chrome Dev Tools 13 | import { composeWithDevTools } from 'redux-devtools-extension'; 14 | 15 | // Business domain imports 16 | import IAppState from './IAppState.interface'; 17 | import CharacterReducer from '../character/reducers/CharacterReducer'; 18 | import { charactersSaga } from '../character/sagas/Character'; 19 | 20 | // Saga Middleware 21 | const sagaMiddleware = createSagaMiddleware(); 22 | 23 | // Create the root reducer 24 | const rootReducer = combineReducers({ 25 | characterState: CharacterReducer, 26 | }); 27 | 28 | // Create a configure store function of type `IAppState` 29 | export default function configureStore(): Store { 30 | const store = createStore( 31 | rootReducer, 32 | undefined, 33 | composeWithDevTools(applyMiddleware(sagaMiddleware)) 34 | ); 35 | 36 | sagaMiddleware.run(charactersSaga); 37 | 38 | return store; 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------