├── .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 |
62 |

by Danial Khosravi

63 |
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 | --------------------------------------------------------------------------------