├── .babelrc
├── .gitignore
├── README.md
├── index.html
├── package.json
├── server.js
├── src
├── App.js
├── actions
│ └── grid.js
├── components
│ ├── Box.js
│ └── Grid.js
├── index.js
├── reducers
│ ├── grid.js
│ ├── index.js
│ └── status.js
└── utils
│ └── sudoku.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "react"]
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | .DS_Store
4 | dist
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## To run:
2 | 1. Make sure you have `node` and `npm` installed
3 | 2. Run `npm install` to install the dependencies
4 | 3. Run `npm start` to start the server
5 | 4. Head to `localhost:3000`
6 |
7 |
8 | ## Built wth:
9 | * ES6 using [Babel](https://babeljs.io/) transpiler
10 | * [React](https://facebook.github.io/react/)
11 | * [Redux](http://redux.js.org/)
12 | * [Lodash](https://lodash.com])
13 | * [Webpack](https://webpack.github.io/)
14 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Sudoku
4 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react_redux_sudoku",
3 | "version": "1.0.1",
4 | "description": "Sudoku written in React and Redux",
5 | "scripts": {
6 | "start": "node server.js"
7 | },
8 | "author": "Danial Khosravi ",
9 | "license": "MIT",
10 | "devDependencies": {
11 | "babel-core": "^6.0.20",
12 | "babel-loader": "^6.0.1",
13 | "babel-preset-es2015": "^6.0.15",
14 | "babel-preset-react": "^6.0.15",
15 | "react-hot-loader": "^1.3.0",
16 | "webpack": "^1.12.2",
17 | "webpack-dev-server": "^1.12.1"
18 | },
19 | "dependencies": {
20 | "lodash": "^4.0.0",
21 | "react": "^0.14.5",
22 | "react-addons-css-transition-group": "^0.14.5",
23 | "react-dom": "^0.14.0",
24 | "redux": "^3.0.5",
25 | "redux-thunk": "^1.0.3"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var WebpackDevServer = require('webpack-dev-server');
3 | var config = require('./webpack.config');
4 |
5 | new WebpackDevServer(webpack(config), {
6 | publicPath: config.output.publicPath,
7 | hot: true,
8 | historyApiFallback: true
9 | }).listen(3000, 'localhost', function(err, result) {
10 | if (err) {
11 | console.log(err);
12 | }
13 | console.log('Listening at localhost:3000');
14 | });
15 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Grid from './components/Grid';
3 | import { solver, isSolvable, isComplete } from './utils/sudoku';
4 | import { solve, clear, undo} from './actions/grid';
5 |
6 | /* Application Container Component */
7 | const App = React.createClass({
8 | componentDidMount(){
9 | this.unsubscribe = this.props.store.subscribe(() => {
10 | this.forceUpdate();
11 | })
12 | },
13 | componentWillUnmount() {
14 | this.unsubscribe();
15 | },
16 | render() {
17 | const {store} = this.props;
18 | const {grid, status} = store.getState();
19 | const {isSolved, isEdited} = status;
20 | return (
21 |
22 |
29 |
36 |
37 |
38 |
39 |
55 |
61 |
64 |
65 |
66 | );
67 | }
68 | });
69 |
70 | export default App;
71 |
--------------------------------------------------------------------------------
/src/actions/grid.js:
--------------------------------------------------------------------------------
1 | /* Action Creators */
2 |
3 | export function inputValue(row, col, val) {
4 | return {
5 | type: 'INPUT_VALUE',
6 | row,
7 | col,
8 | val
9 | };
10 | }
11 |
12 | export function solve() {
13 | return {
14 | type: 'SOLVE'
15 | };
16 | }
17 |
18 | export function clear() {
19 | return {
20 | type: 'CLEAR'
21 | };
22 | }
23 |
24 | export function undo() {
25 | return {
26 | type: 'UNDO'
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/Box.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
3 | import { inputValue } from '../actions/grid';
4 |
5 | const pallet = {
6 | '0': '#90CAF9', // Box 1
7 | '30': '#1DE9B6', // Box 2
8 | '60': '#FFAB91', // Box 3
9 | '3': '#D1C4E9', // Box 4
10 | '33': '#FFF59D', // Box 5
11 | '63': '#A5D6A7', // Box 6
12 | '6': '#80CBC4', // Box 7
13 | '36': '#F48FB1', // Box 8
14 | '66': '#81D4FA', // Box 9
15 | };
16 |
17 | const getBoxColor = (row, col) => {
18 | let rowGroup = row - (row % 3); // uppermost row index of the box
19 | let colGroup = (col - (col % 3)) * 10; // leftmost col index of the box * 10
20 | /*
21 | r\c| 0 30 60
22 | ----------------
23 | 0 | 0 30 60
24 | 3 | 3 33 63
25 | 6 | 5 36 66
26 | */
27 | return pallet[rowGroup + colGroup];
28 | };
29 |
30 | /* Box Component */
31 |
32 | const Box = React.createClass({
33 | componentWillMount() {
34 | const {val} = this.props;
35 | this.setState({isFixed: val ? true : false});
36 | },
37 | shouldComponentUpdate(nextProps, nextState) {
38 | return nextProps.val !== this.props.val;
39 | },
40 | handleChange(e){
41 | const {row, col, store} = this.props;
42 | const range = [1, 2, 3, 4, 5, 6, 7, 8, 9];
43 | const val = parseInt(e.target.value);
44 | const isDeleted = e.target.value === '';
45 |
46 | if (range.indexOf(val) > -1 || isDeleted) {
47 | store.dispatch(inputValue(row, col, isDeleted ? 0 : val));
48 | }
49 | },
50 | render() {
51 | const {row, col, val, isSolved} = this.props;
52 | const {isFixed} = this.state;
53 | const input = (
54 |
62 | );
63 |
64 | return (
65 |
66 | {
67 | isSolved ?
68 | (
69 |
76 | {input}
77 |
78 | ) :
79 | input
80 | }
81 | |
82 | );
83 | }
84 | });
85 |
86 | export default Box;
87 |
--------------------------------------------------------------------------------
/src/components/Grid.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Box from './Box';
3 |
4 | /* Grid Component */
5 | const Grid = React.createClass({
6 | render() {
7 | const {grid, status} = this.props;
8 | const {isSolved} = status;
9 | const renderBox = (row, val, col) => {
10 | return (
11 |
19 | );
20 | };
21 | const renderRow = (vals, row) => {
22 | return (
23 |
24 | {vals.map(renderBox.bind(this, row))}
25 |
26 | );
27 | };
28 |
29 | return (
30 |
31 |
32 | {grid.map(renderRow)}
33 |
34 |
35 |
36 | );
37 | }
38 | });
39 |
40 | export default Grid;
41 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { createStore, compose, applyMiddleware } from 'redux';
4 | import thunk from 'redux-thunk';
5 | import rootReducer from './reducers';
6 | import App from './App';
7 |
8 | const finalCreateStore = compose(
9 | applyMiddleware(thunk),
10 | window.devToolsExtension ? window.devToolsExtension() : f => f
11 | )(createStore);
12 |
13 | const store = finalCreateStore(rootReducer);
14 |
15 | ReactDOM.render(
16 | ,
17 | document.getElementById('root')
18 | );
--------------------------------------------------------------------------------
/src/reducers/grid.js:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'lodash/cloneDeep';
2 | import { default as extend } from 'lodash/assignIn';
3 | import { solver } from '../utils/sudoku';
4 |
5 | const initialState = [
6 | [8, 0, 0, 4, 0, 6, 0, 0, 7],
7 | [0, 0, 0, 0, 0, 0, 4, 0, 0],
8 | [0, 1, 0, 0, 0, 0, 6, 5, 0],
9 | [5, 0, 9, 0, 3, 0, 7, 8, 0],
10 | [0, 0, 0, 0, 7, 0, 0, 0, 0],
11 | [0, 4, 8, 0, 2, 0, 1, 0, 3],
12 | [0, 5, 2, 0, 0, 0, 0, 9, 0],
13 | [0, 0, 1, 0, 0, 0, 0, 0, 0],
14 | [3, 0, 0, 9, 0, 2, 0, 0, 5]
15 | ];
16 |
17 | window.gridHistory = window.gridHistory || [];
18 |
19 | export function grid(state = cloneDeep(initialState), action) {
20 | switch (action.type) {
21 | case 'INPUT_VALUE':
22 | let {row, col, val} = action;
23 | let changedRow = [
24 | ...state[row].slice(0, col),
25 | val,
26 | ...state[row].slice(col + 1)
27 | ]; // Omit using splice since it mutates the state
28 | gridHistory.push(state);
29 | return [
30 | ...state.slice(0, row),
31 | changedRow,
32 | ...state.slice(row + 1)
33 | ];
34 | case 'SOLVE':
35 | let originalClone = cloneDeep(initialState); // originalClone will be mutated by solver()
36 | solver(originalClone);
37 | window.gridHistory = [];
38 | return originalClone;
39 | case 'CLEAR':
40 | window.gridHistory = [];
41 | return cloneDeep(initialState);
42 | case 'UNDO':
43 | let lastState = window.gridHistory.splice(gridHistory.length - 1, 1);
44 | return lastState[0];
45 | default:
46 | return state;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { grid } from './grid';
3 | import { status } from './status';
4 |
5 | const rootReducer = combineReducers({
6 | grid,
7 | status
8 | });
9 |
10 | export default rootReducer;
11 |
--------------------------------------------------------------------------------
/src/reducers/status.js:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'lodash/cloneDeep';
2 | import { default as extend } from 'lodash/assignIn';
3 |
4 | const initialState = {
5 | isSolved: false,
6 | isEdited: false
7 | };
8 |
9 | export function status(state = cloneDeep(initialState), action) {
10 | switch (action.type) {
11 | case 'INPUT_VALUE':
12 | return extend({}, state, {isEdited: true});
13 | case 'SOLVE':
14 | return extend({}, state, {isSolved: true, isEdited: true});
15 | case 'CLEAR':
16 | return extend({}, state, {isSolved: false, isEdited: false});
17 | case 'UNDO':
18 | if (!window.gridHistory.length) {
19 | return extend({}, state, {isEdited: false});
20 | }
21 | return state;
22 | default:
23 | return state;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/utils/sudoku.js:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'lodash/cloneDeep';
2 | import flatten from 'lodash/flatten';
3 | import range from 'lodash/range';
4 | import includes from 'lodash/includes';
5 |
6 | const VALUES = range(1, 10);
7 | const DIM = range(0, 9);
8 | const ZERO = 0;
9 |
10 | const getRow = (grid, rowNum) => {
11 | if (!contains(DIM, rowNum)) {
12 | throw new Error('rowNum not in range');
13 | }
14 | return grid[rowNum];
15 | }
16 |
17 | const getCol = (grid, colNum) => {
18 | if (!contains(DIM, colNum)) {
19 | throw new Error('colNum not in range');
20 | }
21 | return grid.map((row) => row[colNum]);
22 | }
23 |
24 | const getSquare = (grid, rowNum, colNum) => {
25 | if (!contains(DIM, rowNum) || !contains(DIM, colNum)) {
26 | throw new Error('rowNum or colNum are not in range');
27 | }
28 | let rowStart = rowNum - (rowNum % 3); // uppermost row index of the box
29 | let colStart = colNum - (colNum % 3); // leftmost col index of the box
30 | let result = [];
31 | for (let i = 0; i < 3; i++) {
32 | result[i] = result[i] || [];
33 | for (let j = 0; j < 3; j++) {
34 | result[i].push(grid[rowStart + i][colStart + j]);
35 | }
36 | }
37 | return flatten(result);
38 | }
39 |
40 | /*
41 | sudoku constraints checker
42 | - unique in its row
43 | - unique in its column
44 | - unique in its box
45 | */
46 | const check = (grid, number, rowNum, colNum) => {
47 | if (!contains(DIM, rowNum) || !contains(DIM, colNum)) {
48 | throw new Error('rowNum or colNum are not in range');
49 | }
50 |
51 | if (!contains(VALUES, number)) {
52 | throw new Error('number is not in range');
53 | }
54 |
55 | let row = getRow(grid, rowNum);
56 | let column = getCol(grid, colNum);
57 | let square = getSquare(grid, rowNum, colNum);
58 |
59 | if (!contains(row, number) && !contains(column, number) && !contains(square, number)) {
60 | return true;
61 | }
62 |
63 | return false;
64 | }
65 |
66 | /*
67 | starts from 0x0 and moves left to right and row by row to 9x9
68 | */
69 | const getNext = (rowNum = 0, colNum = 0) => {
70 | return colNum !== 8 ? [rowNum, colNum + 1] :
71 | rowNum !== 8 ? [rowNum + 1, 0] :
72 | [0, 0];
73 | }
74 |
75 | /*
76 | Recursive formula that starts from [0, 0] and check
77 | all the possbile values for empty boxes until it reaches
78 | the end of the grid and returns true
79 | or else if the grid is not solvable, it will return false
80 | */
81 | export const solver = (grid, rowNum = 0, colNum = 0) => {
82 | if (contains(DIM, rowNum) < 0 || contains(DIM, colNum) < 0) {
83 | throw new Error('rowNum or colNum are not in range');
84 | }
85 | let isLast = (rowNum >= 8 && colNum >= 8);
86 |
87 | /* if the box is not empty, run the solver on the next box */
88 | if (grid[rowNum][colNum] !== ZERO && !isLast) {
89 | let [nextRowNum, nextColNum] = getNext(rowNum, colNum);
90 | return solver(grid, nextRowNum, nextColNum);
91 | }
92 | /*
93 | if the box is empty, check to see out of numbers 1 to 9,
94 | which one satisfies all three sudoko constraints
95 | */
96 | for (let i = 1; i <= 9; i++) {
97 | if (check(grid, i, rowNum, colNum)) {
98 | grid[rowNum][colNum] = i;
99 | let [nextRowNum, nextColNum] = getNext(rowNum, colNum);
100 | /*
101 | runs the solver recusively until it sucessfully
102 | reaches to the end of the grid, box 9x9
103 | */
104 | if (!nextRowNum && !nextColNum) { // at index [8, 8], next would be [0, 0]
105 | return true;
106 | }
107 | if (solver(grid, nextRowNum, nextColNum)) {
108 | return true;
109 | }
110 | }
111 | }
112 |
113 | /*
114 | if the loop could not solve and return the function,
115 | false will be retuened which indicates the sudoku is not solvable.
116 | resets the current state back to 0 allow for further tries
117 | */
118 | grid[rowNum][colNum] = ZERO;
119 | return false;
120 | }
121 |
122 | export const isSolvable = (grid) => {
123 | let clonedGrid = cloneDeep(grid);
124 | return solver(clonedGrid);
125 | }
126 |
127 | /*
128 | If each of the numbers from 1 to 9 are repeated on the grid 9 times
129 | indicates the suduko is completed/solved
130 | */
131 | export const isComplete = (grid) => {
132 | let values = flatten(grid);
133 | let list = {};
134 | values.map((val) => list[val] = list[val] ? list[val] + 1 : 1);
135 | delete list['0'];
136 | var total = Object.keys(list).reduce((total, key) => total + list[key], 0);
137 | return total === 81 ? true : false;
138 | }
139 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var webpack = require('webpack');
3 |
4 | module.exports = {
5 | devtool: 'eval',
6 | entry: [
7 | 'webpack-dev-server/client?http://localhost:3000',
8 | 'webpack/hot/only-dev-server',
9 | './src/index'
10 | ],
11 | output: {
12 | path: path.join(__dirname, 'dist'),
13 | filename: 'bundle.js',
14 | publicPath: '/static/'
15 | },
16 | plugins: [
17 | new webpack.HotModuleReplacementPlugin()
18 | ],
19 | module: {
20 | loaders: [{
21 | test: /\.js$/,
22 | loaders: ['react-hot', 'babel'],
23 | include: path.join(__dirname, 'src')
24 | }]
25 | }
26 | };
27 |
--------------------------------------------------------------------------------