├── .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 |
19 | {grid.map((rows, rowColumn) =>
20 | rows.map((row, rowIndex) => (
21 |
30 | )),
31 | )}
32 |
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 | gameRestart()}
237 | label="New Game"
238 | />
239 |
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 |
--------------------------------------------------------------------------------