├── .babelrc ├── .eslintrc ├── .gitignore ├── .nvmrc ├── README.md ├── circle.yml ├── content └── app.gif ├── package.json ├── src ├── __mocks__ │ └── api.js ├── actions │ ├── __tests__ │ │ └── gameActions.spec.js │ ├── actionTypes.js │ └── gameActions.js ├── components │ ├── BoardGrid.js │ ├── BoardSpace.js │ ├── __tests__ │ │ ├── BoardGrid.spec.js │ │ ├── BoardSpace.spec.js │ │ └── __snapshots__ │ │ │ ├── BoardGrid.spec.js.snap │ │ │ └── BoardSpace.spec.js.snap │ └── common │ │ ├── Button.js │ │ ├── Icon.js │ │ └── Modal.js ├── containers │ ├── App.js │ └── __tests__ │ │ ├── App.spec.js │ │ └── __snapshots__ │ │ └── App.spec.js.snap ├── index.html ├── index.js ├── reducers │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── gameReducer.spec.js.snap │ │ └── gameReducer.spec.js │ ├── game.js │ ├── gameHelpers.js │ └── index.js └── store │ └── configureStore.js ├── webpack.config.js └── webpack.prod.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "es2015", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "react-hot-loader/babel" 9 | ], 10 | "env": { 11 | "development": { 12 | "plugins": ["transform-react-jsx-source"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "jest": true 7 | }, 8 | "settings": { 9 | "import/resolver": "webpack" 10 | }, 11 | "rules": { 12 | "react/jsx-filename-extension": 0, 13 | "import/extensions": 0, 14 | "max-len": [1, 120, 4], 15 | "jsx-a11y/no-static-element-interactions": 0, 16 | "react/no-unescaped-entities": 0, 17 | "react/forbid-prop-types": 0 18 | } 19 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | dist 5 | .idea 6 | yarn.lock 7 | coverage -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 6.9.1 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

React Tic Tac Toe

2 | 3 |

4 | 5 |

6 | 7 |

8 | 9 | 10 |

11 | 12 | ## Installing 13 | 14 | - With yarn: 15 | ```sh 16 | yarn install 17 | ``` 18 | 19 | - With npm: 20 | ```sh 21 | npm install 22 | ``` 23 | 24 | ## Running 25 | 26 | ### Development 27 | 28 | Simply run: 29 | ```sh 30 | npm run watch 31 | ``` 32 | 33 | And open [http://localhost:7000/](http://localhost:7000/) on your favorite browser. 34 | 35 | ### Production 36 | 37 | - Build the app, the files will be available in `./dist`: 38 | ```sh 39 | npm run build 40 | ``` 41 | 42 | - Run with amazing [http-server](https://github.com/indexzero/http-server): 43 | ```sh 44 | npm start 45 | ``` 46 | 47 | - And open [http://localhost:7000/](http://localhost:7000/) also on your favorite browser. 😉 48 | 49 | ## Lint 50 | 51 | Simply type `npm run lint` on your terminal to lint using [ESLint](http://eslint.org/) following [Airbnb's JavaScript Styleguide](https://github.com/airbnb/javascript). 52 | 53 | ## Testing 54 | 55 | This project is using [Jest](https://github.com/facebook/jest) for testing, simply type `npm test` on the root folder to see the magic! -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.9.1 4 | dependencies: 5 | pre: 6 | # Install yarn 7 | - sudo apt-key adv --fetch-keys http://dl.yarnpkg.com/debian/pubkey.gpg 8 | - echo "deb http://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list 9 | - sudo apt-get update -qq 10 | - sudo apt-get install -y -qq yarn 11 | cache_directories: 12 | - ~/.yarn-cache 13 | override: 14 | - yarn install 15 | test: 16 | pre: 17 | - npm run lint 18 | override: 19 | - npm run test -- --runInBand -------------------------------------------------------------------------------- /content/app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasbento/react-tic-tac-toe/7f9f007ca54e10aa98548cd3b6f1e0037437d61f/content/app.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tic-tac-toe", 3 | "description": "Tic Tac Toe in React", 4 | "version": "1.0.0", 5 | "author": { 6 | "name": "Lucas Bento da Silva", 7 | "email": "lucas.bsilva@outlook.com", 8 | "url": "https://github.com/lucasbento" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/lucasbento/react-tic-tac-toe/issues" 12 | }, 13 | "dependencies": { 14 | "babel-polyfill": "6.20.0", 15 | "glamor": "^2.20.22", 16 | "lodash.clonedeep": "^4.5.0", 17 | "react": "15.3.2", 18 | "react-dom": "^15.4.1", 19 | "react-fontawesome": "^1.5.0", 20 | "react-hot-loader": "^3.0.0-beta.5", 21 | "react-modal-dialog": "^4.0.4", 22 | "react-redux": "^4.4.5", 23 | "redux": "^3.6.0", 24 | "redux-thunk": "^2.1.0" 25 | }, 26 | "devDependencies": { 27 | "babel-core": "6.20.0", 28 | "babel-eslint": "7.1.1", 29 | "babel-jest": "^18.0.0", 30 | "babel-loader": "6.2.9", 31 | "babel-plugin-transform-react-jsx-source": "^6.9.0", 32 | "babel-preset-es2015": "6.18.0", 33 | "babel-preset-react": "6.16.0", 34 | "babel-preset-stage-0": "6.16.0", 35 | "clean-webpack-plugin": "^0.1.11", 36 | "enzyme": "^2.7.0", 37 | "enzyme-to-json": "^1.4.5", 38 | "eslint": "^3.9.1", 39 | "eslint-config-airbnb": "^13.0.0", 40 | "eslint-import-resolver-webpack": "^0.8.0", 41 | "eslint-plugin-import": "^2.1.0", 42 | "eslint-plugin-jsx-a11y": "^2.2.3", 43 | "eslint-plugin-react": "^6.6.0", 44 | "html-webpack-plugin": "^2.22.0", 45 | "jest": "^18.1.0", 46 | "react-addons-test-utils": "^15.4.1", 47 | "redux-mock-store": "^1.2.1", 48 | "webpack": "^1.13.2", 49 | "webpack-dev-server": "^1.16.1" 50 | }, 51 | "homepage": "https://github.com/lucasbento/react-tic-tac-toe", 52 | "jest": { 53 | "collectCoverage": true, 54 | "collectCoverageFrom": [ 55 | "src/**/*.{js,jsx}" 56 | ], 57 | "testPathIgnorePatterns": [ 58 | "/node_modules/", 59 | "./dist" 60 | ], 61 | "coverageReporters": [ 62 | "lcov", 63 | "html" 64 | ] 65 | }, 66 | "license": "MIT", 67 | "main": "dist/index.js", 68 | "repository": { 69 | "type": "git", 70 | "url": "https://github.com/lucasbento/react-tic-tac-toe.git" 71 | }, 72 | "scripts": { 73 | "build": "webpack --production --config webpack.prod.config.js --progress --profile --colors", 74 | "lint": "eslint --ext jsx --ext js src", 75 | "start": "http-server ./dist -p 7000", 76 | "test": "jest --coverage", 77 | "watch": "webpack-dev-server --config webpack.config.js --progress --profile --colors" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/__mocks__/api.js: -------------------------------------------------------------------------------- 1 | export function checkIt(value) { 2 | return new Promise((resolve, reject) => { 3 | if (value && value[0] === '@') { 4 | resolve(); 5 | return; 6 | } 7 | 8 | reject(new Error('Value should start with `@`')); 9 | }); 10 | } 11 | 12 | export function submitIt(data) { 13 | // This will be a helper variable to test successful and failed request 14 | const { shouldResolve } = data; 15 | return new Promise((resolve, reject) => { 16 | if (shouldResolve) { 17 | resolve(data); 18 | return; 19 | } 20 | 21 | reject(new Error('BANG! Try again later')); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/actions/__tests__/gameActions.spec.js: -------------------------------------------------------------------------------- 1 | import configureMockStore from 'redux-mock-store'; 2 | import thunk from 'redux-thunk'; 3 | 4 | import types from '../actionTypes'; 5 | import * as actions from '../gameActions'; 6 | import { initialState } from '../../reducers/game'; 7 | 8 | describe('Game Actions', () => { 9 | it('should create an action to choose a player\'s symbol mark', () => { 10 | const symbol = 'X'; 11 | const action = actions.gameChoosePlayer(symbol); 12 | 13 | expect(action.type).toBe(types.GAME.CHOOSE_PLAYER); 14 | expect(action.symbol).toBe(symbol); 15 | }); 16 | 17 | it('should create an action to verify the status of the game', () => { 18 | const action = actions.gameVerify(); 19 | 20 | expect(action.type).toBe(types.GAME.VERIFY); 21 | }); 22 | 23 | it('should create an action to change to next player', () => { 24 | const action = actions.nextPlayer(); 25 | 26 | expect(action.type).toBe(types.GAME.NEXT_PLAYER); 27 | }); 28 | 29 | it('should create an action to create new mark and handle gameVerify & nextPlayer', async () => { 30 | const column = 0; 31 | const row = 1; 32 | 33 | const mockStore = configureMockStore([thunk]); 34 | 35 | const store = mockStore(initialState); 36 | 37 | store.dispatch(actions.newMark(column, row)); 38 | 39 | const dispatchedActions = store.getActions(); 40 | 41 | expect(dispatchedActions).toEqual([ 42 | { 43 | type: types.NEW_MARK, 44 | column, 45 | row, 46 | }, 47 | { 48 | type: types.GAME.VERIFY, 49 | }, 50 | { 51 | type: types.GAME.NEXT_PLAYER, 52 | }, 53 | ]); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/actions/actionTypes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | NEW_MARK: 'NEW_MARK', 3 | GAME: { 4 | CHOOSE_PLAYER: 'GAME_CHOOSE_PLAYER', 5 | VERIFY: 'GAME_VERIFY', 6 | RESTART: 'GAME_RESTART', 7 | NEXT_PLAYER: 'GAME_NEXT_PLAYER', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/actions/gameActions.js: -------------------------------------------------------------------------------- 1 | import types from './actionTypes'; 2 | 3 | export const gameChoosePlayer = symbol => ({ 4 | type: types.GAME.CHOOSE_PLAYER, 5 | symbol, 6 | }); 7 | 8 | export const gameVerify = () => ({ 9 | type: types.GAME.VERIFY, 10 | }); 11 | 12 | export const nextPlayer = () => ({ 13 | type: types.GAME.NEXT_PLAYER, 14 | }); 15 | 16 | export const newMark = (column, row) => 17 | (dispatch) => { 18 | dispatch({ 19 | type: types.NEW_MARK, 20 | column, 21 | row, 22 | }); 23 | 24 | dispatch(gameVerify()); 25 | 26 | return dispatch(nextPlayer()); 27 | }; 28 | 29 | export const gameRestart = () => ({ 30 | type: types.GAME.RESTART, 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/BoardGrid.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import BoardSpace from './BoardSpace'; 4 | 5 | const styles = { 6 | board: { 7 | width: 300, 8 | listStyleType: 'none', 9 | display: 'flex', 10 | flexDirection: 'row', 11 | justifyContent: 'space-between', 12 | flexWrap: 'wrap', 13 | padding: 0, 14 | }, 15 | }; 16 | 17 | const BoardGrid = ({ grid, handleNewMark, isWinnerMark, getPlayerSymbol }) => ( 18 | 33 | ); 34 | 35 | BoardGrid.propTypes = { 36 | /** 37 | * The grid of the game's board along with its values. 38 | */ 39 | grid: PropTypes.arrayOf( 40 | PropTypes.arrayOf( 41 | PropTypes.number, 42 | ).isRequired, 43 | ).isRequired, 44 | /** 45 | * A function to handle a new mark on a clicked board space. 46 | */ 47 | handleNewMark: PropTypes.func.isRequired, 48 | /** 49 | * A function to check if the board space is in the winner marks. 50 | */ 51 | isWinnerMark: PropTypes.func.isRequired, 52 | /** 53 | * A function to return the player's chosen symbol. 54 | */ 55 | getPlayerSymbol: PropTypes.func.isRequired, 56 | }; 57 | 58 | export default BoardGrid; 59 | -------------------------------------------------------------------------------- /src/components/BoardSpace.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { css } from 'glamor'; 3 | 4 | import Icon from './common/Icon'; 5 | 6 | const styles = { 7 | boardSpace: css({ 8 | fontSize: 40, 9 | width: 95, 10 | height: 95, 11 | border: '1px solid indigo', 12 | display: 'flex', 13 | justifyContent: 'center', 14 | alignItems: 'center', 15 | marginBottom: 5, 16 | ':hover': { 17 | boxShadow: '0px 0px 21px 0px rgba(43, 0, 130, 0.28)', 18 | }, 19 | }), 20 | winnerBoardSpace: css({ 21 | color: '#0291E8', 22 | }), 23 | }; 24 | 25 | const SYMBOLS = { 26 | X: 'times', 27 | O: 'circle-o', 28 | }; 29 | 30 | const BoardSpace = ({ column, index, value, handleNewMark, isWinnerMark, getPlayerSymbol }) => { 31 | const icon = (value) ? SYMBOLS[getPlayerSymbol(value)] : null; 32 | 33 | const containerStyle = (isWinnerMark(column, index)) ? { 34 | ...styles.boardSpace, 35 | ...styles.winnerBoardSpace, 36 | } : styles.boardSpace; 37 | 38 | return ( 39 |
  • handleNewMark(column, index)} 42 | > 43 | {(value !== null) ? 44 | : 45 | null 46 | } 47 |
  • 48 | ); 49 | }; 50 | 51 | BoardSpace.propTypes = { 52 | /** 53 | * The column of the space in the board grid. 54 | */ 55 | column: PropTypes.number.isRequired, 56 | /** 57 | * The index (row) of the space in the board grid. 58 | */ 59 | index: PropTypes.number.isRequired, 60 | /** 61 | * The board space value. 62 | */ 63 | value: PropTypes.number, 64 | /** 65 | * A function to handle a new mark on a clicked board space. 66 | */ 67 | handleNewMark: PropTypes.func.isRequired, 68 | /** 69 | * A function to check if the board space is in the winner marks. 70 | */ 71 | isWinnerMark: PropTypes.func.isRequired, 72 | /** 73 | * A function to return the player's chosen symbol. 74 | */ 75 | getPlayerSymbol: PropTypes.func.isRequired, 76 | }; 77 | 78 | export default BoardSpace; 79 | -------------------------------------------------------------------------------- /src/components/__tests__/BoardGrid.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { shallowToJson } from 'enzyme-to-json'; 4 | 5 | import { initialState } from '../../reducers/game'; 6 | 7 | import BoardGrid from '../BoardGrid'; 8 | 9 | function setupComponent() { 10 | const props = { 11 | grid: initialState.boardGrid, 12 | handleNewMark: jest.fn(), 13 | isWinnerMark: jest.fn(), 14 | getPlayerSymbol: jest.fn(), 15 | }; 16 | 17 | const wrapper = shallow( 18 | , 19 | ); 20 | 21 | return { 22 | props, 23 | wrapper, 24 | }; 25 | } 26 | 27 | describe('', () => { 28 | it('should render without exploding', () => { 29 | const { wrapper } = setupComponent(); 30 | 31 | expect(shallowToJson(wrapper)).toMatchSnapshot(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/__tests__/BoardSpace.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { shallowToJson } from 'enzyme-to-json'; 4 | 5 | import BoardSpace from '../BoardSpace'; 6 | 7 | function setupComponent() { 8 | const props = { 9 | column: 2, 10 | index: 1, 11 | value: 1, 12 | handleNewMark: jest.fn(), 13 | isWinnerMark: jest.fn(), 14 | getPlayerSymbol: () => 'X', 15 | }; 16 | 17 | const wrapper = shallow( 18 | , 19 | ); 20 | 21 | return { 22 | props, 23 | wrapper, 24 | }; 25 | } 26 | 27 | describe('', () => { 28 | it('should render without exploding', () => { 29 | const { wrapper } = setupComponent(); 30 | 31 | expect(shallowToJson(wrapper)).toMatchSnapshot(); 32 | }); 33 | 34 | it('should handle new mark on board space click', () => { 35 | const { wrapper, props } = setupComponent(); 36 | 37 | wrapper.find('li').first().simulate('click'); 38 | 39 | expect(props.handleNewMark.mock.calls).toEqual([ 40 | [props.column, props.index], 41 | ]); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/BoardGrid.spec.js.snap: -------------------------------------------------------------------------------- 1 | exports[` should render without exploding 1`] = ` 2 |
      14 | 21 | 28 | 35 | 42 | 49 | 56 | 63 | 70 | 77 |
    78 | `; 79 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/BoardSpace.spec.js.snap: -------------------------------------------------------------------------------- 1 | exports[` should render without exploding 1`] = ` 2 |
  • 5 | 7 |
  • 8 | `; 9 | -------------------------------------------------------------------------------- /src/components/common/Button.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import Icon from './Icon'; 4 | 5 | const styles = { 6 | button: { 7 | padding: 15, 8 | fontSize: 16, 9 | fontFamily: 'Montserrat, sans-serif', 10 | textTransform: 'uppercase', 11 | backgroundColor: '#0291E8', 12 | color: '#FFFFFF', 13 | border: 0, 14 | borderRadius: 2, 15 | cursor: 'pointer', 16 | minWidth: 120, 17 | }, 18 | iconBefore: { 19 | marginRight: 10, 20 | }, 21 | iconAfter: { 22 | marginLeft: 10, 23 | }, 24 | }; 25 | 26 | const Button = ({ icon, iconPosition = 'before', disabled, label, style, ...props }) => ( 27 | 51 | ); 52 | 53 | Button.propTypes = { 54 | /** 55 | * The icon to show along with the Button's `label`. 56 | */ 57 | icon: PropTypes.string, 58 | /** 59 | * The position of which the icon will be shown. 60 | */ 61 | iconPosition: PropTypes.oneOf(['before', 'after']), 62 | /** 63 | * Text to show inside of button. 64 | */ 65 | label: PropTypes.string, 66 | /** 67 | * Whether this button is on disabled or not. 68 | */ 69 | disabled: PropTypes.bool, 70 | /** 71 | * Custom button style. 72 | */ 73 | style: PropTypes.object, // eslint-disable-line react/forbid-prop-types 74 | }; 75 | 76 | export default Button; 77 | -------------------------------------------------------------------------------- /src/components/common/Icon.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import FontAwesome from 'react-fontawesome'; 3 | 4 | const Icon = ({ name, ...props }) => ( 5 | 9 | ); 10 | 11 | Icon.propTypes = { 12 | /** 13 | * The name of the icon. 14 | */ 15 | name: PropTypes.string.isRequired, 16 | }; 17 | 18 | export default Icon; 19 | 20 | -------------------------------------------------------------------------------- /src/components/common/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { ModalContainer, ModalDialog } from 'react-modal-dialog'; 3 | 4 | const Modal = ({ show, children, onClose }) => ( 5 | show && ( 6 | 7 | 8 | {children} 9 | 10 | 11 | ) 12 | ); 13 | 14 | Modal.propTypes = { 15 | /** 16 | * Whether the modal should show or not. 17 | */ 18 | show: PropTypes.bool.isRequired, 19 | /** 20 | * The component to be rendered inside of the modal. 21 | */ 22 | children: PropTypes.node, 23 | /** 24 | * The function to be called when the user clicks outside of the modal or on the close button. 25 | */ 26 | onClose: PropTypes.func, 27 | }; 28 | 29 | export default Modal; 30 | -------------------------------------------------------------------------------- /src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { bindActionCreators } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | 5 | import * as gameActions from '../actions/gameActions'; 6 | 7 | import BoardGrid from '../components/BoardGrid'; 8 | import Button from '../components/common/Button'; 9 | import Modal from '../components/common/Modal'; 10 | 11 | const styles = { 12 | container: { 13 | marginTop: 20, 14 | }, 15 | boardContainer: { 16 | display: 'flex', 17 | alignItems: 'center', 18 | flexDirection: 'column', 19 | }, 20 | actionsContainer: { 21 | display: 'flex', 22 | justifyContent: 'center', 23 | }, 24 | modalActionsContainer: { 25 | display: 'flex', 26 | justifyContent: 'center', 27 | }, 28 | pageTitle: { 29 | borderBottom: '1px solid #CCC', 30 | marginBottom: 15, 31 | padding: '0 40px 10px', 32 | }, 33 | title: { 34 | fontSize: 18, 35 | textAlign: 'center', 36 | }, 37 | modalTitle: { 38 | fontSize: 30, 39 | marginTop: 0, 40 | textAlign: 'center', 41 | }, 42 | modalSubtitle: { 43 | fontSize: 27, 44 | marginTop: 0, 45 | textAlign: 'center', 46 | }, 47 | button: { 48 | margin: '0px 5px', 49 | padding: 5, 50 | fontSize: 25, 51 | minWidth: 60, 52 | }, 53 | currentPlayerContainer: { 54 | border: '1px solid #0291E8', 55 | padding: 10, 56 | borderRadius: 5, 57 | }, 58 | }; 59 | 60 | export class App extends Component { 61 | static propTypes = { 62 | /** 63 | * The grid of the game's board along with its values. 64 | */ 65 | boardGrid: PropTypes.arrayOf( 66 | PropTypes.arrayOf( 67 | PropTypes.number, 68 | ).isRequired, 69 | ).isRequired, 70 | /** 71 | * The current player of the game. 72 | */ 73 | currentPlayer: PropTypes.number, 74 | /** 75 | * The marks that won the game. 76 | */ 77 | winnerMarks: PropTypes.object, 78 | /** 79 | * The player that won the game (if it exists). 80 | */ 81 | winnerPlayer: PropTypes.number, 82 | /** 83 | * Whether the game is a tie or not. 84 | */ 85 | isTie: PropTypes.bool, 86 | /** 87 | * The chosen players symbols. 88 | */ 89 | playersSymbols: PropTypes.object, 90 | /** 91 | * The quantity of chosen marks of the game. 92 | */ 93 | marksCount: PropTypes.number, 94 | actions: PropTypes.shape({ 95 | /** 96 | * An action to restart the game to the initial state. 97 | */ 98 | gameRestart: PropTypes.func, 99 | /** 100 | * An action to choose the first player. 101 | */ 102 | gameChoosePlayer: PropTypes.func, 103 | /** 104 | * An action to handle a new mark on a board space. 105 | */ 106 | newMark: PropTypes.func, 107 | }), 108 | }; 109 | 110 | state = { 111 | showChoosePlayerModal: true, 112 | }; 113 | 114 | getPlayerSymbol = player => this.props.playersSymbols[player] || null; 115 | 116 | isWinnerMark = (column, index) => { 117 | const { winnerMarks } = this.props; 118 | 119 | if (!winnerMarks || !winnerMarks[column]) { 120 | return false; 121 | } 122 | 123 | return (winnerMarks[column].indexOf(index) !== -1); 124 | }; 125 | 126 | handleNewMark = (column, row) => { 127 | const { boardGrid, winnerPlayer } = this.props; 128 | 129 | // Don't let user mark if it's an already-marked board space or there's already a winner 130 | if (boardGrid[column][row] || winnerPlayer) { 131 | return null; 132 | } 133 | 134 | return this.props.actions.newMark(column, row); 135 | }; 136 | 137 | choosePlayer = (player) => { 138 | this.props.actions.gameChoosePlayer(player); 139 | 140 | this.setState({ 141 | showChoosePlayerModal: false, 142 | }); 143 | }; 144 | 145 | render() { 146 | const { showChoosePlayerModal } = this.state; 147 | const { 148 | boardGrid, 149 | currentPlayer, 150 | winnerMarks, 151 | winnerPlayer, 152 | isTie, 153 | marksCount, 154 | actions: { 155 | gameRestart, 156 | }, 157 | } = this.props; 158 | 159 | return ( 160 |
    161 |
    162 |

    Tic Tac Toe

    163 | 164 | {!showChoosePlayerModal && ( 165 |
    166 | Current player: {currentPlayer} 167 |
    168 | )} 169 | 170 | 177 |
    178 | 179 | {marksCount > 0 && ( 180 |
    181 |
    187 | )} 188 | 189 | 190 | 191 |

    What do you want to play with?

    192 | 193 |
    194 |
    210 |
    211 | 212 | gameRestart()} 215 | > 216 | {isTie && ( 217 |
    218 |

    It's a tie!

    219 | 220 |

    You are both equally strong! 😁

    221 |
    222 | )} 223 | 224 | {winnerPlayer && ( 225 |
    226 |

    Player {winnerPlayer} is the winner!

    227 | 228 |

    You rock! 😎

    229 |
    230 | )} 231 | 232 |
    233 |
    240 |
    241 |
    242 | ); 243 | } 244 | } 245 | 246 | const mapStateToProps = ({ game }) => ({ ...game }); 247 | 248 | const mapDispatchToProps = dispatch => ({ 249 | actions: bindActionCreators(gameActions, dispatch), 250 | }); 251 | 252 | export default connect(mapStateToProps, mapDispatchToProps)(App); 253 | -------------------------------------------------------------------------------- /src/containers/__tests__/App.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import configureMockStore from 'redux-mock-store'; 3 | import thunk from 'redux-thunk'; 4 | import { shallow } from 'enzyme'; 5 | import { shallowToJson } from 'enzyme-to-json'; 6 | 7 | import { initialState as game } from '../../reducers/game'; 8 | import ConnectedApp, { App } from '../App'; 9 | 10 | const middlewares = [thunk]; 11 | const mockStore = configureMockStore(middlewares); 12 | 13 | function setupComponent() { 14 | const props = { 15 | ...game, 16 | actions: { 17 | gameRestart: jest.fn(), 18 | gameChoosePlayer: jest.fn(), 19 | newMark: jest.fn(), 20 | }, 21 | }; 22 | 23 | const wrapper = shallow( 24 | , 25 | ); 26 | 27 | return { 28 | props, 29 | wrapper, 30 | }; 31 | } 32 | 33 | describe('', () => { 34 | it('should render without exploding', () => { 35 | const store = mockStore({ 36 | game, 37 | }); 38 | 39 | const wrapper = shallow( 40 | , 41 | ); 42 | 43 | expect(shallowToJson(wrapper)).toMatchSnapshot(); 44 | }); 45 | 46 | it('should choose symbol `X` on initialization', () => { 47 | const { wrapper, props } = setupComponent(); 48 | 49 | // Simulate a click to choose the `X` symbol 50 | wrapper.find('Button[name="symbol-X"]').simulate('click'); 51 | 52 | expect(props.actions.gameChoosePlayer.mock.calls).toEqual([ 53 | ['X'], 54 | ]); 55 | 56 | // Mount component again 57 | const { wrapper: newWrapper, props: newProps } = setupComponent(); 58 | 59 | // Simulate a click to choose the `O` symbol 60 | newWrapper.find('Button[name="symbol-O"]').simulate('click'); 61 | 62 | expect(newProps.actions.gameChoosePlayer.mock.calls).toEqual([ 63 | ['O'], 64 | ]); 65 | }); 66 | 67 | it('should choose symbol `O` on initialization', () => { 68 | const { wrapper, props } = setupComponent(); 69 | 70 | // Simulate a click to choose the `O` symbol 71 | wrapper.find('Button[name="symbol-O"]').simulate('click'); 72 | 73 | expect(props.actions.gameChoosePlayer.mock.calls).toEqual([ 74 | ['O'], 75 | ]); 76 | }); 77 | 78 | it('should restart the game on button click', () => { 79 | const { wrapper, props } = setupComponent(); 80 | 81 | wrapper.find('Button[name="restart"]').simulate('click'); 82 | 83 | expect(props.actions.gameRestart.mock.calls.length).toBe(1); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/containers/__tests__/__snapshots__/App.spec.js.snap: -------------------------------------------------------------------------------- 1 | exports[` should render without exploding 1`] = ` 2 | 47 | `; 48 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Tic Tac Toe 6 | 7 | 8 | 15 | 16 | 17 |
    18 |
    19 | 20 | 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import React from 'react'; 4 | import { render } from 'react-dom'; 5 | import { Provider } from 'react-redux'; 6 | import { AppContainer } from 'react-hot-loader'; 7 | 8 | import configureStore from './store/configureStore'; 9 | 10 | import ConnectedApp from './containers/App'; 11 | 12 | const rootEl = document.getElementById('root'); 13 | 14 | const store = configureStore(); 15 | 16 | render( 17 | 18 | 19 | 20 | 21 | , 22 | rootEl, 23 | ); 24 | 25 | if (module.hot) { 26 | module.hot.accept('./containers/App', () => { 27 | const NextApp = require('./containers/App').default; // eslint-disable-line global-require 28 | 29 | render( 30 | 31 | 32 | 33 | 34 | , 35 | document.getElementById('root'), 36 | ); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/reducers/__tests__/__snapshots__/gameReducer.spec.js.snap: -------------------------------------------------------------------------------- 1 | exports[`Game Reducer should change to the next player 1`] = ` 2 | Object { 3 | "boardGrid": Array [ 4 | Array [ 5 | null, 6 | null, 7 | null, 8 | ], 9 | Array [ 10 | null, 11 | null, 12 | null, 13 | ], 14 | Array [ 15 | null, 16 | null, 17 | null, 18 | ], 19 | ], 20 | "currentPlayer": 2, 21 | "isTie": false, 22 | "marksCount": 0, 23 | "playersSymbols": Object {}, 24 | "winnerMarks": Object {}, 25 | "winnerPlayer": null, 26 | } 27 | `; 28 | 29 | exports[`Game Reducer should choose the player 1`] = ` 30 | Object { 31 | "boardGrid": Array [ 32 | Array [ 33 | null, 34 | null, 35 | null, 36 | ], 37 | Array [ 38 | null, 39 | null, 40 | null, 41 | ], 42 | Array [ 43 | null, 44 | null, 45 | null, 46 | ], 47 | ], 48 | "currentPlayer": 1, 49 | "isTie": false, 50 | "marksCount": 0, 51 | "playersSymbols": Object { 52 | "1": "X", 53 | "2": "O", 54 | }, 55 | "winnerMarks": Object {}, 56 | "winnerPlayer": null, 57 | } 58 | `; 59 | 60 | exports[`Game Reducer should define game as tie 1`] = ` 61 | Object { 62 | "boardGrid": Array [ 63 | Array [ 64 | null, 65 | null, 66 | null, 67 | ], 68 | Array [ 69 | null, 70 | null, 71 | null, 72 | ], 73 | Array [ 74 | null, 75 | null, 76 | null, 77 | ], 78 | ], 79 | "currentPlayer": 1, 80 | "isTie": true, 81 | "marksCount": 9, 82 | "playersSymbols": Object {}, 83 | "winnerMarks": Object {}, 84 | "winnerPlayer": null, 85 | } 86 | `; 87 | 88 | exports[`Game Reducer should define player 1 as winner on diagonal board spaces 1`] = ` 89 | Object { 90 | "boardGrid": Array [ 91 | Array [ 92 | 1, 93 | null, 94 | null, 95 | ], 96 | Array [ 97 | null, 98 | 1, 99 | null, 100 | ], 101 | Array [ 102 | null, 103 | null, 104 | 1, 105 | ], 106 | ], 107 | "currentPlayer": 1, 108 | "isTie": false, 109 | "marksCount": 0, 110 | "playersSymbols": Object {}, 111 | "winnerMarks": Object { 112 | "0": Array [ 113 | 0, 114 | ], 115 | "1": Array [ 116 | 1, 117 | ], 118 | "2": Array [ 119 | 2, 120 | ], 121 | }, 122 | "winnerPlayer": 1, 123 | } 124 | `; 125 | 126 | exports[`Game Reducer should define player 1 as winner on vertical board spaces 1`] = ` 127 | Object { 128 | "boardGrid": Array [ 129 | Array [ 130 | 1, 131 | null, 132 | null, 133 | ], 134 | Array [ 135 | 1, 136 | null, 137 | null, 138 | ], 139 | Array [ 140 | 1, 141 | null, 142 | null, 143 | ], 144 | ], 145 | "currentPlayer": 1, 146 | "isTie": false, 147 | "marksCount": 0, 148 | "playersSymbols": Object {}, 149 | "winnerMarks": Object { 150 | "0": Array [ 151 | 0, 152 | ], 153 | "1": Array [ 154 | 0, 155 | ], 156 | "2": Array [ 157 | 0, 158 | ], 159 | }, 160 | "winnerPlayer": 1, 161 | } 162 | `; 163 | 164 | exports[`Game Reducer should handle a new mark 1`] = ` 165 | Object { 166 | "boardGrid": Array [ 167 | Array [ 168 | null, 169 | 1, 170 | null, 171 | ], 172 | Array [ 173 | null, 174 | null, 175 | null, 176 | ], 177 | Array [ 178 | null, 179 | null, 180 | null, 181 | ], 182 | ], 183 | "currentPlayer": 1, 184 | "isTie": false, 185 | "marksCount": 1, 186 | "playersSymbols": Object {}, 187 | "winnerMarks": Object {}, 188 | "winnerPlayer": null, 189 | } 190 | `; 191 | 192 | exports[`Game Reducer should restart to the initial state but keep the chosen player symbol 1`] = ` 193 | Object { 194 | "boardGrid": Array [ 195 | Array [ 196 | null, 197 | null, 198 | null, 199 | ], 200 | Array [ 201 | null, 202 | null, 203 | null, 204 | ], 205 | Array [ 206 | null, 207 | null, 208 | null, 209 | ], 210 | ], 211 | "currentPlayer": 1, 212 | "isTie": false, 213 | "marksCount": 0, 214 | "playersSymbols": Object { 215 | "1": "X", 216 | "2": "O", 217 | }, 218 | "winnerMarks": Object {}, 219 | "winnerPlayer": null, 220 | } 221 | `; 222 | -------------------------------------------------------------------------------- /src/reducers/__tests__/gameReducer.spec.js: -------------------------------------------------------------------------------- 1 | import gameReducer, { initialState as state } from '../game'; 2 | import { BOARD_SIZE } from '../../reducers/gameHelpers'; 3 | import types from '../../actions/actionTypes'; 4 | 5 | describe('Game Reducer', () => { 6 | it('should return initial state', () => { 7 | expect(gameReducer()).toBe(state); 8 | }); 9 | 10 | it('should choose the player', () => { 11 | expect( 12 | gameReducer(state, { 13 | type: types.GAME.CHOOSE_PLAYER, 14 | symbol: 'X', 15 | }), 16 | ).toMatchSnapshot(); 17 | }); 18 | 19 | it('should handle a new mark', () => { 20 | expect( 21 | gameReducer(state, { 22 | type: types.NEW_MARK, 23 | column: 0, 24 | row: 1, 25 | }), 26 | ).toMatchSnapshot(); 27 | }); 28 | 29 | it('should define player 1 as winner on vertical board spaces', () => { 30 | expect( 31 | gameReducer({ 32 | ...state, 33 | boardGrid: [ 34 | [ 35 | 1, 36 | null, 37 | null, 38 | ], 39 | [ 40 | 1, 41 | null, 42 | null, 43 | ], 44 | [ 45 | 1, 46 | null, 47 | null, 48 | ], 49 | ], 50 | }, { 51 | type: types.GAME.VERIFY, 52 | }), 53 | ).toMatchSnapshot(); 54 | }); 55 | 56 | it('should define player 1 as winner on diagonal board spaces', () => { 57 | expect( 58 | gameReducer({ 59 | ...state, 60 | boardGrid: [ 61 | [ 62 | 1, 63 | null, 64 | null, 65 | ], 66 | [ 67 | null, 68 | 1, 69 | null, 70 | ], 71 | [ 72 | null, 73 | null, 74 | 1, 75 | ], 76 | ], 77 | }, { 78 | type: types.GAME.VERIFY, 79 | }), 80 | ).toMatchSnapshot(); 81 | }); 82 | 83 | it('should define game as tie', () => { 84 | expect( 85 | gameReducer({ 86 | ...state, 87 | marksCount: BOARD_SIZE ** 2, 88 | }, { 89 | type: types.GAME.VERIFY, 90 | }), 91 | ).toMatchSnapshot(); 92 | }); 93 | 94 | it('should change to the next player', () => { 95 | expect( 96 | gameReducer(state, { 97 | type: types.GAME.NEXT_PLAYER, 98 | }), 99 | ).toMatchSnapshot(); 100 | }); 101 | 102 | it('should restart to the initial state but keep the chosen player symbol', () => { 103 | expect( 104 | gameReducer({ 105 | ...state, 106 | playersSymbols: { 107 | 1: 'X', 108 | 2: 'O', 109 | }, 110 | }, { 111 | type: types.GAME.RESTART, 112 | }), 113 | ).toMatchSnapshot(); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/reducers/game.js: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash.clonedeep'; 2 | import types from '../actions/actionTypes'; 3 | import { 4 | BOARD_SIZE, 5 | generateBoardGrid, 6 | verifyBoardSpaces, 7 | } from './gameHelpers'; 8 | 9 | export const initialState = { 10 | boardGrid: generateBoardGrid(), 11 | currentPlayer: 1, 12 | playersSymbols: {}, 13 | winnerMarks: {}, 14 | winnerPlayer: null, 15 | marksCount: 0, 16 | isTie: false, 17 | }; 18 | 19 | export default (state = initialState, action = {}) => { 20 | switch (action.type) { 21 | case types.GAME.CHOOSE_PLAYER: { 22 | return { 23 | ...state, 24 | playersSymbols: { 25 | 1: action.symbol, 26 | 2: (action.symbol === 'X') ? 'O' : 'X', 27 | }, 28 | }; 29 | } 30 | case types.NEW_MARK: { 31 | const { currentPlayer } = state; 32 | const boardGrid = cloneDeep(state.boardGrid); // Using `cloneDeep` so `boardGrid` isn't copied with references 33 | 34 | const { column, row } = action; 35 | 36 | boardGrid[column][row] = currentPlayer; 37 | 38 | return { 39 | ...state, 40 | boardGrid, 41 | marksCount: state.marksCount + 1, 42 | }; 43 | } 44 | case types.GAME.VERIFY: { 45 | const boardSize = BOARD_SIZE; 46 | const maxMarksCount = boardSize ** 2; 47 | 48 | const { currentPlayer: winnerPlayer, marksCount } = state; 49 | 50 | if (marksCount === maxMarksCount) { 51 | return { 52 | ...state, 53 | isTie: true, 54 | }; 55 | } 56 | 57 | const boardGrid = cloneDeep(state.boardGrid); 58 | 59 | for (let i = 0; i < boardSize; i += 1) { 60 | // Check if user won horizontally 61 | if (verifyBoardSpaces(boardGrid[i][0], boardGrid[i][1], boardGrid[i][2])) { 62 | return { 63 | ...state, 64 | winnerMarks: { 65 | [i]: [0, 1, 2], 66 | }, 67 | winnerPlayer, 68 | }; 69 | } 70 | 71 | // Check if user won vertically 72 | if (verifyBoardSpaces(boardGrid[0][i], boardGrid[1][i], boardGrid[2][i])) { 73 | return { 74 | ...state, 75 | winnerMarks: { 76 | 0: [i], 77 | 1: [i], 78 | 2: [i], 79 | }, 80 | winnerPlayer, 81 | }; 82 | } 83 | } 84 | 85 | /* 86 | Check if user won diagonally 87 | */ 88 | if (verifyBoardSpaces(boardGrid[0][0], boardGrid[1][1], boardGrid[2][2])) { 89 | return { 90 | ...state, 91 | winnerMarks: { 92 | 0: [0], 93 | 1: [1], 94 | 2: [2], 95 | }, 96 | winnerPlayer, 97 | }; 98 | } 99 | 100 | if (verifyBoardSpaces(boardGrid[0][2], boardGrid[1][1], boardGrid[2][0])) { 101 | return { 102 | ...state, 103 | winnerMarks: { 104 | 0: [2], 105 | 1: [1], 106 | 2: [0], 107 | }, 108 | winnerPlayer, 109 | }; 110 | } 111 | 112 | return state; 113 | } 114 | case types.GAME.NEXT_PLAYER: { 115 | return { 116 | ...state, 117 | currentPlayer: ((state.currentPlayer === 1) ? 2 : 1), 118 | }; 119 | } 120 | case types.GAME.RESTART: { 121 | return { 122 | ...initialState, 123 | playersSymbols: state.playersSymbols, 124 | }; 125 | } 126 | default: { 127 | return state; 128 | } 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /src/reducers/gameHelpers.js: -------------------------------------------------------------------------------- 1 | export const BOARD_SIZE = 3; 2 | 3 | export const generateBoardGrid = () => { 4 | const boardSize = BOARD_SIZE; 5 | 6 | // Initializes an array with three `undefined` indexes 7 | const boardGrid = new Array(boardSize); 8 | 9 | // Loop through these indexes 10 | for (let i = 0; i < boardSize; i += 1) { 11 | const boardRows = new Array(boardSize); 12 | 13 | // And create three rows with `null` value for each one of them 14 | for (let j = 0; j < boardSize; j += 1) { 15 | boardRows[j] = null; 16 | } 17 | 18 | // Append the rows to the grid 19 | boardGrid[i] = boardRows; 20 | } 21 | 22 | return boardGrid; 23 | }; 24 | 25 | export const verifyBoardSpaces = (firstSpace, secondSpace, thirdSpace) => ( 26 | firstSpace !== null && 27 | firstSpace === secondSpace && 28 | firstSpace === thirdSpace 29 | ); 30 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import game from './game'; 4 | 5 | const reducer = combineReducers({ 6 | game, 7 | }); 8 | 9 | export default reducer; 10 | -------------------------------------------------------------------------------- /src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import reducers from '../reducers'; 4 | 5 | function configureStore() { 6 | const enhancer = compose( 7 | applyMiddleware(thunk), 8 | ); 9 | 10 | const store = createStore(reducers, enhancer); 11 | 12 | return store; 13 | } 14 | 15 | export default configureStore; 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const webpack = require('webpack'); 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const HOST = process.env.HOST || 'localhost'; 8 | const PORT = process.env.PORT || '7000'; 9 | 10 | module.exports = { 11 | root: [ 12 | path.resolve(__dirname, './src'), 13 | ], 14 | entry: [ 15 | 'react-hot-loader/patch', 16 | `webpack-dev-server/client?http://${HOST}:${PORT}`, 17 | `webpack/hot/only-dev-server`, 18 | `./src/index.js` 19 | ], 20 | devtool: 'source-map', 21 | output: { 22 | path: path.join(__dirname, 'dist'), 23 | filename: 'bundle.js', 24 | }, 25 | resolve: { 26 | extensions: ['', '.js'], 27 | }, 28 | module: { 29 | loaders: [{ 30 | test: /\.jsx?$/, 31 | exclude: /node_modules/, 32 | loaders: ['babel'], 33 | }], 34 | }, 35 | devServer: { 36 | contentBase: './build', 37 | noInfo: true, 38 | hot: true, 39 | inline: true, 40 | historyApiFallback: true, 41 | port: PORT, 42 | host: HOST, 43 | }, 44 | plugins: [ 45 | new webpack.HotModuleReplacementPlugin(), 46 | new HtmlWebpackPlugin({ 47 | template: './src/index.html' 48 | }), 49 | ] 50 | }; 51 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const webpack = require('webpack'); 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const CleanPlugin = require('clean-webpack-plugin'); 7 | 8 | module.exports = { 9 | context: path.join(__dirname, './src'), 10 | entry: './index.js', 11 | output: { 12 | path: path.join(__dirname, 'dist'), 13 | filename: '[chunkhash].js', 14 | }, 15 | resolve: { 16 | extensions: ['', '.js'], 17 | }, 18 | module: { 19 | loaders: [{ 20 | test: /\.jsx?$/, 21 | exclude: /node_modules/, 22 | loaders: ['babel'], 23 | }], 24 | }, 25 | plugins: [ 26 | new webpack.DefinePlugin({ 27 | 'process.env.NODE_ENV': JSON.stringify('production'), 28 | }), 29 | new webpack.optimize.UglifyJsPlugin({ 30 | compress: { 31 | warnings: false, 32 | screw_ie8: true, 33 | drop_console: true, 34 | drop_debugger: true, 35 | }, 36 | output: { 37 | comments: false, 38 | }, 39 | }), 40 | new webpack.optimize.DedupePlugin(), 41 | new CleanPlugin(['dist'], { verbose: false }), 42 | new webpack.optimize.OccurenceOrderPlugin(), 43 | new HtmlWebpackPlugin({ 44 | template: './index.html', 45 | }), 46 | ] 47 | }; 48 | --------------------------------------------------------------------------------