├── uploads └── .gitkeep ├── src ├── home │ ├── style.scss │ └── Home.tsx ├── counter │ ├── style.scss │ ├── __tests__ │ │ ├── module.spec.ts │ │ ├── Counter.spec.tsx │ │ └── ActionDispatcher.spec.ts │ ├── Counter.tsx │ ├── Container.tsx │ └── module.ts ├── Variables.ts ├── NotFound.tsx ├── __tests__ │ └── enzyme-util.ts ├── store.ts ├── Index.tsx ├── upload │ ├── FileUploadSample.tsx │ ├── module.ts │ └── Container.tsx ├── Routes.tsx └── dnd │ ├── Container.tsx │ └── Card.tsx ├── config ├── variables │ ├── dev.js │ └── prod.js ├── dev-server.js ├── webpack.config.prod.js └── webpack.config.dev.js ├── .gitignore ├── public └── images │ └── favicon.ico ├── moveToPublic.sh ├── prettier.config.js ├── index.template.ejs ├── .circleci └── config.yml ├── .eslintrc.js ├── tsconfig.json ├── Readme.md ├── LICENSE.txt ├── karma.conf.js └── package.json /uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/home/style.scss: -------------------------------------------------------------------------------- 1 | .App { 2 | color: brown; 3 | } -------------------------------------------------------------------------------- /src/counter/style.scss: -------------------------------------------------------------------------------- 1 | .App { 2 | color: blue; 3 | } -------------------------------------------------------------------------------- /config/variables/dev.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ENV_PRODUCTION: false, 3 | } 4 | -------------------------------------------------------------------------------- /config/variables/prod.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ENV_PRODUCTION: true, 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | public/bundle/ 4 | public/libs/ 5 | public/index.html 6 | uploads/ -------------------------------------------------------------------------------- /src/Variables.ts: -------------------------------------------------------------------------------- 1 | declare var ENV_PRODUCTION: boolean 2 | 3 | export const PRODUCTION = ENV_PRODUCTION 4 | -------------------------------------------------------------------------------- /public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uryyyyyyy/react-redux-sample/HEAD/public/images/favicon.ico -------------------------------------------------------------------------------- /src/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default function NotFound() { 4 | return
Not Found
5 | } 6 | -------------------------------------------------------------------------------- /src/__tests__/enzyme-util.ts: -------------------------------------------------------------------------------- 1 | import * as Enzyme from 'enzyme' 2 | import * as Adapter from 'enzyme-adapter-react-16' 3 | Enzyme.configure({ adapter: new Adapter() }) 4 | -------------------------------------------------------------------------------- /moveToPublic.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf ./public/libs/ 4 | mkdir -p ./public/libs/ 5 | cp ./node_modules/react/umd/react.production.min.js ./public/libs/ 6 | cp ./node_modules/react-dom/umd/react-dom.production.min.js ./public/libs/ 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: false, 6 | singleQuote: true, 7 | bracketSpacing: true, 8 | arrowParens: 'always', 9 | parser: 'typescript' 10 | } 11 | -------------------------------------------------------------------------------- /src/home/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const styles = require('./style.scss') 3 | 4 | export function Home() { 5 | return ( 6 |
7 |

Home

8 |

{"it's home."}

9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /index.template.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Sample 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import counter, { CounterActions, CounterState } from './counter/module' 2 | import upload, { FileUploadActions, FileUploadState } from './upload/module' 3 | import { createStore, combineReducers, Action } from 'redux' 4 | 5 | export default createStore( 6 | combineReducers({ 7 | counter, 8 | upload 9 | }) 10 | ) 11 | 12 | export interface ReduxState { 13 | counter: CounterState 14 | upload: FileUploadState 15 | } 16 | 17 | export type ReduxAction = CounterActions | FileUploadActions | Action 18 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:8.9.4-browsers 6 | environment: 7 | TZ: "/usr/share/zoneinfo/Asia/Tokyo" 8 | steps: 9 | - checkout 10 | - restore_cache: 11 | key: dependency-cache 12 | - run: npm install --no-save 13 | - save_cache: 14 | key: dependency-cache 15 | paths: 16 | - "~/.npm/_cacache" 17 | - run: npm run lint:all 18 | - run: npm run build:prod 19 | - run: npm run test:all 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "plugin:prettier/recommended", 4 | "plugin:react/recommended" 5 | ], 6 | "plugins": [ 7 | "@typescript-eslint", 8 | "prettier", 9 | "react" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "sourceType": "module", 14 | "project": "./tsconfig.json" 15 | }, 16 | "rules": { 17 | }, 18 | "settings": { 19 | "react": { 20 | "version": "detect" 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictBindCallApply": true, 4 | "strictNullChecks": true, 5 | "noUnusedLocals" : true, 6 | "noImplicitThis": true, 7 | "noUnusedParameters": true, 8 | "alwaysStrict": true, 9 | "noImplicitAny": true, 10 | "strictFunctionTypes": true, 11 | "strictPropertyInitialization": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "moduleResolution": "node", 15 | "outDir": "./dist/", 16 | "sourceMap": true, 17 | "lib": ["dom", "es2017"], 18 | "module": "ES2015", 19 | "target": "es5", 20 | "jsx": "react" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/counter/__tests__/module.spec.ts: -------------------------------------------------------------------------------- 1 | import reducer, { decrementAmount, CounterState, incrementAmount } from '../module' 2 | import { PRODUCTION } from '../../Variables' 3 | 4 | describe('counter/module', () => { 5 | it('INCREMENT', () => { 6 | const state: CounterState = { num: 4, loadingCount: 0 } 7 | const result = reducer(state, incrementAmount(3)) 8 | expect(result.num).toBe(state.num + 3) 9 | }) 10 | 11 | it('DECREMENT', () => { 12 | const state: CounterState = { num: 4, loadingCount: 0 } 13 | const result = reducer(state, decrementAmount(3)) 14 | expect(result.num).toBe(state.num - 3) 15 | }) 16 | 17 | it('check Env value', () => { 18 | expect(PRODUCTION).toBe(false) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/Index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | import { Router } from 'react-router' 4 | import store from './store' 5 | import { Provider } from 'react-redux' 6 | import { createBrowserHistory } from 'history' 7 | import { Routes } from './Routes' 8 | import { PRODUCTION } from './Variables' 9 | import HTML5Backend from 'react-dnd-html5-backend' 10 | import { DndProvider } from 'react-dnd' 11 | 12 | const history = createBrowserHistory() 13 | 14 | console.log(`this source is for ${PRODUCTION ? 'prod' : 'dev'}`) 15 | 16 | ReactDOM.render( 17 | 18 | 19 | 20 | 21 | 22 | 23 | , 24 | document.getElementById('app') 25 | ) 26 | -------------------------------------------------------------------------------- /src/counter/Counter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { CounterState } from './module' 3 | import { ActionDispatcher } from './Container' 4 | const styles = require('./style.scss') 5 | 6 | interface Props { 7 | value: CounterState 8 | actions: ActionDispatcher 9 | param?: string 10 | } 11 | 12 | export function Counter(props: Props) { 13 | return ( 14 |
15 | {props.param === undefined ? null :
{props.param}
} 16 |

{`score: ${props.value.num}`}

17 | 18 | 19 | 20 | {props.value.loadingCount === 0 ? null :

loading

} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/upload/FileUploadSample.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FileUploadState } from './module' 3 | import { ActionDispatcher } from './Container' 4 | 5 | interface Props { 6 | value: FileUploadState 7 | actions: ActionDispatcher 8 | } 9 | 10 | export function FileUploadSample(props: Props) { 11 | const handleChangeFile = (e: any) => { 12 | const target: HTMLInputElement = e.target as HTMLInputElement 13 | if (target.files == null) { 14 | props.actions.updateFile(null) 15 | } else { 16 | const _file = target.files.item(0) 17 | props.actions.updateFile(_file) 18 | } 19 | } 20 | const file = props.value.file 21 | const button = file ? : null 22 | return ( 23 |
24 |

File upload

25 | handleChangeFile(e)} /> 26 | {button} 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | ## React-redux-sample 3 | 4 | [![CircleCI](https://circleci.com/gh/uryyyyyyy/react-redux-sample/tree/more-tools.svg?style=svg)](https://circleci.com/gh/uryyyyyyy/react-redux-sample/tree/more-tools) 5 | 6 | minimal sample implementation 7 | 8 | see document about it(Japanese) 9 | 10 | - [helloWorld](http://qiita.com/uryyyyyyy/items/63969d6ed9341affdffb) 11 | - [redux](http://qiita.com/uryyyyyyy/items/3ad88cf9ca9393335f8c) 12 | - [redux-test](http://qiita.com/uryyyyyyy/items/7d4b0ede3f2b973d6951) 13 | - [async](http://qiita.com/uryyyyyyy/items/41334a810f1501ece87d) 14 | - [react-router](http://qiita.com/uryyyyyyy/items/30733e9cd140e60c52bd) 15 | - lint & formatter (coming soon) 16 | - build-tuning (coming soon) 17 | 18 | ## requirement 19 | 20 | - NodeJS 8.9~ 21 | - NPM 5.3~ 22 | - Chrome Browser (or fetch-API implemented browser) 23 | 24 | ## setup 25 | 26 | `npm install` 27 | 28 | ## build 29 | 30 | `npm run build` 31 | 32 | ## run 33 | 34 | `npm run server` 35 | 36 | ## License 37 | 38 | MIT 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2017 uryyyyyyy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/counter/__tests__/Counter.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Counter } from '../Counter' 3 | import '../../__tests__/enzyme-util' 4 | import { shallow } from 'enzyme' 5 | import { CounterState } from '../module' 6 | import { ActionDispatcher } from '../Container' 7 | 8 | describe('Counter', () => { 9 | it('rendering', () => { 10 | const actions: any = {} 11 | const state: CounterState = { num: 1, loadingCount: 1 } 12 | const wrapper = shallow() 13 | expect( 14 | wrapper 15 | .find('p') 16 | .at(0) 17 | .prop('children') 18 | ).toBe('score: 1') 19 | expect( 20 | wrapper 21 | .find('p') 22 | .at(1) 23 | .prop('children') 24 | ).toBe('loading') 25 | }) 26 | 27 | it('click', () => { 28 | const actionSpy = new ActionDispatcher(null!) 29 | spyOn(actionSpy, 'increment') 30 | const state: CounterState = { num: 0, loadingCount: 0 } 31 | const wrapper = shallow() 32 | wrapper 33 | .find('button') 34 | .at(0) 35 | .simulate('click') 36 | expect(actionSpy.increment).toHaveBeenCalledWith(3) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /config/dev-server.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const express = require('express') 4 | const app = express() 5 | 6 | const multer = require('multer') 7 | const upload = multer({ dest: './uploads/' }) 8 | 9 | app.use('/public', express.static('public')) 10 | 11 | app.get('/api/count', (req, res) => { 12 | res.contentType('application/json') 13 | const obj = { amount: 100 } 14 | setTimeout(() => res.json(obj), 500) 15 | //res.status(400).json(obj); //for error testing 16 | }) 17 | 18 | app.post('/api/upload', upload.fields([{ name: 'myFile' }]), (req, res) => { 19 | const myFile = req.files.myFile[0] 20 | const tmp_path = myFile.path 21 | const target_path = './uploads/' + myFile.originalname 22 | fs.rename(tmp_path, target_path, (err) => { 23 | if (err) throw err 24 | fs.unlink(tmp_path, function() { 25 | if (err) throw err 26 | setTimeout(() => res.json({ message: `File uploaded to: ${target_path} - ${myFile.size} bytes` }), 500) 27 | }) 28 | }) 29 | }) 30 | 31 | app.get('*', (req, res) => { 32 | res.sendFile(path.join(__dirname, '../public/index.html')) 33 | }) 34 | 35 | app.listen(3000, (err) => { 36 | if (err) { 37 | console.log(err) 38 | } 39 | console.log('server start at port 3000') 40 | }) 41 | -------------------------------------------------------------------------------- /src/Routes.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Switch } from 'react-router' 3 | import { Link, Route } from 'react-router-dom' 4 | import Counter from './counter/Container' 5 | import NotFound from './NotFound' 6 | import { Home } from './home/Home' 7 | import ReactDNDSample from './dnd/Container' 8 | import FileUploadSample from './upload/Container' 9 | 10 | export function Routes() { 11 | return ( 12 |
13 |

React Redux sample

14 |
  • 15 | Home 16 |
  • 17 |
  • 18 | Counter 19 |
  • 20 |
  • 21 | Counter with param 22 |
  • 23 |
  • 24 | Drag and Drop 25 |
  • 26 |
  • 27 | file upload 28 |
  • 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
    38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /config/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const webpack = require('webpack') 5 | const env = require('./variables/prod') 6 | 7 | module.exports = { 8 | mode: 'production', 9 | entry: './src/Index.tsx', 10 | output: { 11 | filename: 'bundle/main.[chunkhash].js', 12 | path: path.resolve('public'), 13 | publicPath: '/public' 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.tsx?$/, 19 | loader: 'ts-loader' 20 | }, 21 | { 22 | test: /\.scss$/, 23 | use: [ 24 | { 25 | loader: "style-loader" 26 | }, { 27 | loader: "css-loader", 28 | options: { 29 | importLoaders: 1, 30 | modules: true 31 | } 32 | }, { 33 | loader: "sass-loader" 34 | } 35 | ] 36 | } 37 | ] 38 | }, 39 | resolve: { 40 | extensions: ['.ts', '.tsx', '.js'] 41 | }, 42 | externals: { 43 | react: 'React', 44 | 'react-dom': 'ReactDOM' 45 | }, 46 | plugins: [ 47 | new HtmlWebpackPlugin({ 48 | filename: 'index.html', 49 | template: 'index.template.ejs', 50 | inject: 'body' 51 | }), 52 | new webpack.DefinePlugin(env) 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /config/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const webpack = require('webpack') 5 | const env = require('./variables/dev') 6 | 7 | module.exports = { 8 | mode: 'development', 9 | devtool: 'source-map', 10 | entry: './src/Index.tsx', 11 | output: { 12 | filename: 'bundle/main.[chunkhash].js', 13 | path: path.resolve('public'), 14 | publicPath: '/public' 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.tsx?$/, 20 | loader: 'ts-loader' 21 | }, 22 | { 23 | test: /\.scss$/, 24 | use: [ 25 | { 26 | loader: 'style-loader' 27 | }, 28 | { 29 | loader: 'css-loader', 30 | options: { 31 | importLoaders: 1, 32 | modules: true 33 | } 34 | }, 35 | { 36 | loader: 'sass-loader' 37 | } 38 | ] 39 | } 40 | ] 41 | }, 42 | resolve: { 43 | extensions: ['.ts', '.tsx', '.js'] 44 | }, 45 | externals: { 46 | react: 'React', 47 | 'react-dom': 'ReactDOM' 48 | }, 49 | plugins: [ 50 | new HtmlWebpackPlugin({ 51 | filename: 'index.html', 52 | template: 'index.template.ejs', 53 | inject: 'body' 54 | }), 55 | new webpack.DefinePlugin(env) 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/dnd/Container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useCallback, useState } from 'react' 3 | import Card from './Card' 4 | import * as _ from 'lodash' 5 | import { RouteComponentProps } from 'react-router' 6 | 7 | interface ICard { 8 | id: number 9 | text: string 10 | } 11 | 12 | const initialCards: ICard[] = [ 13 | { 14 | id: 1, 15 | text: 'Write a cool JS library' 16 | }, 17 | { 18 | id: 2, 19 | text: 'Make it generic enough' 20 | }, 21 | { 22 | id: 3, 23 | text: 'Write README' 24 | }, 25 | { 26 | id: 4, 27 | text: 'Create some examples' 28 | }, 29 | { 30 | id: 5, 31 | text: 'Spam in Twitter and IRC to promote it (note that this element is taller than the others)' 32 | }, 33 | { 34 | id: 6, 35 | text: '???' 36 | }, 37 | { 38 | id: 7, 39 | text: 'PROFIT' 40 | } 41 | ] 42 | 43 | export default function Container(_unused: RouteComponentProps<{}>) { 44 | const [cards, setCards] = useState(initialCards) 45 | 46 | const moveCard = useCallback( 47 | (dragIndex: number, hoverIndex: number) => { 48 | const targetList = _.clone(cards) 49 | const target = _.nth(targetList, dragIndex) 50 | if (!target) return 51 | targetList.splice(dragIndex, 1) 52 | targetList.splice(hoverIndex, 0, target) 53 | setCards(targetList) 54 | }, 55 | [cards] 56 | ) 57 | 58 | return ( 59 |
    60 | {cards.map((card: ICard, i: number) => ( 61 | 62 | ))} 63 |
    64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /src/upload/module.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux' 2 | import { ReduxAction } from '../store' 3 | 4 | enum ActionNames { 5 | UPDATE_FILE = 'upload/update_file', 6 | UPLOAD_START = 'upload/fetch_request_start', 7 | UPLOAD_FINISH = 'upload/fetch_request_finish' 8 | } 9 | 10 | interface UpdateFileAction extends Action { 11 | type: ActionNames.UPDATE_FILE 12 | appAction: boolean 13 | file: File | null 14 | } 15 | export const updateFile = (file: File | null): UpdateFileAction => ({ 16 | type: ActionNames.UPDATE_FILE, 17 | appAction: true, 18 | file 19 | }) 20 | 21 | interface UploadStartAction extends Action { 22 | type: ActionNames.UPLOAD_START 23 | appAction: boolean 24 | } 25 | export const uploadStart = (): UploadStartAction => ({ 26 | type: ActionNames.UPLOAD_START, 27 | appAction: true 28 | }) 29 | 30 | interface UploadFinishAction extends Action { 31 | type: ActionNames.UPLOAD_FINISH 32 | appAction: boolean 33 | } 34 | export const uploadFinish = (): UploadFinishAction => ({ 35 | type: ActionNames.UPLOAD_FINISH, 36 | appAction: true 37 | }) 38 | 39 | export interface FileUploadState { 40 | file: File | null 41 | } 42 | 43 | export type FileUploadActions = UploadStartAction | UploadFinishAction | UpdateFileAction 44 | 45 | const initialState: FileUploadState = { 46 | file: null 47 | } 48 | 49 | export default function reducer(state: FileUploadState = initialState, action: ReduxAction): FileUploadState { 50 | if (!('appAction' in action)) { 51 | return state 52 | } 53 | switch (action.type) { 54 | case ActionNames.UPDATE_FILE: { 55 | return Object.assign({}, state, { file: action.file }) 56 | } 57 | default: 58 | return state 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const env = require('./config/variables/dev') 3 | 4 | const args = process.argv 5 | args.splice(0, 4) 6 | 7 | const polyfills = [] 8 | 9 | const files = polyfills.concat(args) 10 | 11 | module.exports = (config) => { 12 | config.set({ 13 | basePath: '', 14 | frameworks: ['jasmine'], 15 | files: files, 16 | preprocessors: { 17 | '**/*.spec.ts': ['webpack'], 18 | '**/*.spec.tsx': ['webpack'] 19 | }, 20 | mime: { 21 | 'text/x-typescript': ['ts', 'tsx'] 22 | }, 23 | webpack: { 24 | resolve: { 25 | extensions: ['.ts', '.js', '.tsx'] 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.tsx?$/, 31 | use: [ 32 | {loader: "ts-loader"} 33 | ] 34 | }, 35 | { 36 | test: /\.scss$/, 37 | use: [ 38 | { 39 | loader: "style-loader" 40 | }, { 41 | loader: "css-loader", 42 | options: { 43 | importLoaders: 1, 44 | modules: true 45 | } 46 | }, { 47 | loader: "sass-loader" 48 | } 49 | ] 50 | } 51 | ] 52 | }, 53 | plugins: [new webpack.DefinePlugin(env)] 54 | }, 55 | webpackMiddleware: { 56 | stats: 'errors-only', 57 | noInfo: true 58 | }, 59 | reporters: ['mocha'], 60 | port: 9876, 61 | colors: true, 62 | logLevel: config.LOG_INFO, 63 | autoWatch: false, 64 | browsers: ['ChromeHeadless'], 65 | singleRun: true, 66 | concurrency: Infinity 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /src/counter/__tests__/ActionDispatcher.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fetchMock from 'fetch-mock' 2 | import { incrementAmount, fetchRequestStart, fetchRequestFinish } from '../module' 3 | import { ActionDispatcher } from '../Container' 4 | 5 | describe('ActionDispatcher', () => { 6 | beforeEach(() => { 7 | fetchMock.restore() 8 | }) 9 | 10 | it('increment', () => { 11 | const spy: any = { dispatch: null } 12 | spyOn(spy, 'dispatch') 13 | const actions = new ActionDispatcher(spy.dispatch) 14 | actions.increment(100) 15 | expect(spy.dispatch.calls.count()).toEqual(1) 16 | expect(spy.dispatch.calls.argsFor(0)[0]).toEqual(incrementAmount(100)) 17 | }) 18 | 19 | it('fetchAmount success', async (done) => { 20 | fetchMock.get('/api/count', { body: { amount: 100 }, status: 200 }) 21 | 22 | const spy: any = { dispatch: null } 23 | spyOn(spy, 'dispatch') 24 | const actions = new ActionDispatcher(spy.dispatch) 25 | await actions.asyncIncrement() 26 | expect(spy.dispatch.calls.count()).toEqual(3) 27 | expect(spy.dispatch.calls.argsFor(0)[0]).toEqual(fetchRequestStart()) 28 | expect(spy.dispatch.calls.argsFor(1)[0]).toEqual(incrementAmount(100)) 29 | expect(spy.dispatch.calls.argsFor(2)[0]).toEqual(fetchRequestFinish()) 30 | done() 31 | }) 32 | 33 | it('fetchAmount fail', async (done) => { 34 | fetchMock.get('/api/count', { body: {}, status: 400 }) 35 | 36 | const spy: any = { dispatch: null } 37 | spyOn(spy, 'dispatch') 38 | const actions = new ActionDispatcher(spy.dispatch) 39 | await actions.asyncIncrement() 40 | expect(spy.dispatch.calls.count()).toEqual(2) 41 | expect(spy.dispatch.calls.argsFor(0)[0]).toEqual(fetchRequestStart()) 42 | expect(spy.dispatch.calls.argsFor(1)[0]).toEqual(fetchRequestFinish()) 43 | done() 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/counter/Container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Counter } from './Counter' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | import { 5 | CounterActions, 6 | CounterState, 7 | decrementAmount, 8 | fetchRequestFinish, 9 | fetchRequestStart, 10 | incrementAmount 11 | } from './module' 12 | import { Dispatch } from 'redux' 13 | import { ReduxState } from '../store' 14 | import { RouteComponentProps } from 'react-router' 15 | 16 | export class ActionDispatcher { 17 | constructor(private dispatch: Dispatch) {} 18 | 19 | myHeaders = new Headers({ 20 | 'Content-Type': 'application/json', 21 | Accept: 'application/json', 22 | 'X-Requested-With': 'XMLHttpRequest' 23 | }) 24 | 25 | public increment(amount: number): void { 26 | this.dispatch(incrementAmount(amount)) 27 | } 28 | 29 | public decrement(amount: number): void { 30 | this.dispatch(decrementAmount(amount)) 31 | } 32 | 33 | public async asyncIncrement(): Promise { 34 | this.dispatch(fetchRequestStart()) 35 | 36 | try { 37 | const response: Response = await fetch('/api/count', { 38 | method: 'GET', 39 | headers: this.myHeaders 40 | }) 41 | 42 | if (response.status !== 200) { 43 | throw new Error(`illegal status code: ${response.status}`) 44 | } 45 | const json: { amount: number } = await response.json() 46 | this.dispatch(incrementAmount(json.amount)) 47 | } catch (err) { 48 | console.error(err) 49 | } finally { 50 | this.dispatch(fetchRequestFinish()) 51 | } 52 | } 53 | } 54 | 55 | type RouterProps = RouteComponentProps<{ myParams: string | undefined }> 56 | 57 | export default function CounterContainer(props: RouterProps) { 58 | const count = useSelector((state) => { 59 | return state.counter 60 | }) 61 | return 62 | } 63 | -------------------------------------------------------------------------------- /src/upload/Container.tsx: -------------------------------------------------------------------------------- 1 | import { FileUploadSample } from './FileUploadSample' 2 | import { connect, MapDispatchToPropsParam, MapStateToPropsParam } from 'react-redux' 3 | import { Dispatch } from 'redux' 4 | import { FileUploadState, updateFile, uploadFinish, uploadStart } from './module' 5 | import { ReduxAction, ReduxState } from '../store' 6 | import { RouteComponentProps } from 'react-router' 7 | 8 | export class ActionDispatcher { 9 | constructor(private dispatch: (action: ReduxAction) => void) {} 10 | 11 | myHeaders = new Headers({ 12 | Accept: 'application/json', 13 | 'X-Requested-With': 'XMLHttpRequest' 14 | }) 15 | 16 | public updateFile(file: File | null): void { 17 | this.dispatch(updateFile(file)) 18 | } 19 | 20 | public async upload(file: File): Promise { 21 | this.dispatch(uploadStart()) 22 | const formData = new FormData() 23 | formData.append('myFile', file) 24 | try { 25 | const response: Response = await fetch('/api/upload', { 26 | method: 'POST', 27 | body: formData, 28 | headers: this.myHeaders 29 | }) 30 | if (response.ok) { 31 | // 2xx 32 | const json = await response.json() 33 | console.log(json) 34 | alert('upload done') 35 | } else { 36 | throw new Error(`illegal status code: ${response.status}`) 37 | } 38 | } catch (err) { 39 | console.error(err) 40 | } finally { 41 | this.dispatch(uploadFinish()) 42 | } 43 | } 44 | } 45 | 46 | type RouterProps = RouteComponentProps<{}> 47 | 48 | const mapStateToProps: MapStateToPropsParam<{ value: FileUploadState }, RouterProps, ReduxState> = ( 49 | state: ReduxState, 50 | _: RouterProps 51 | ) => { 52 | return { value: state.upload } 53 | } 54 | 55 | const mapDispatchToProps: MapDispatchToPropsParam<{ actions: ActionDispatcher }, {}> = ( 56 | dispatch: Dispatch 57 | ) => ({ actions: new ActionDispatcher(dispatch) }) 58 | 59 | export default connect( 60 | mapStateToProps, 61 | mapDispatchToProps 62 | )(FileUploadSample) 63 | -------------------------------------------------------------------------------- /src/counter/module.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux' 2 | 3 | enum ActionNames { 4 | INC = 'counter/increment', 5 | DEC = 'counter/decrement', 6 | FETCH_START = 'counter/fetch_request_start', 7 | FETCH_FINISH = 'counter/fetch_request_finish' 8 | } 9 | 10 | interface IncrementAction extends Action { 11 | type: ActionNames.INC 12 | plusAmount: number 13 | } 14 | export const incrementAmount = (amount: number): IncrementAction => ({ 15 | type: ActionNames.INC, 16 | plusAmount: amount 17 | }) 18 | 19 | interface DecrementAction extends Action { 20 | type: ActionNames.DEC 21 | minusAmount: number 22 | } 23 | export const decrementAmount = (amount: number): DecrementAction => ({ 24 | type: ActionNames.DEC, 25 | minusAmount: amount 26 | }) 27 | 28 | interface FetchRequestStartAction extends Action { 29 | type: ActionNames.FETCH_START 30 | } 31 | export const fetchRequestStart = (): FetchRequestStartAction => ({ 32 | type: ActionNames.FETCH_START 33 | }) 34 | 35 | interface FetchRequestFinishAction extends Action { 36 | type: ActionNames.FETCH_FINISH 37 | } 38 | export const fetchRequestFinish = (): FetchRequestFinishAction => ({ 39 | type: ActionNames.FETCH_FINISH 40 | }) 41 | 42 | export interface CounterState { 43 | num: number 44 | loadingCount: number 45 | } 46 | 47 | export type CounterActions = IncrementAction | DecrementAction | FetchRequestStartAction | FetchRequestFinishAction 48 | 49 | const initialState: CounterState = { 50 | num: 0, 51 | loadingCount: 0 52 | } 53 | 54 | export default function reducer(state: CounterState = initialState, action: CounterActions): CounterState { 55 | switch (action.type) { 56 | case ActionNames.INC: 57 | return Object.assign({}, state, { num: state.num + action.plusAmount }) 58 | case ActionNames.DEC: 59 | return Object.assign({}, state, { num: state.num - action.minusAmount }) 60 | case ActionNames.FETCH_START: { 61 | return Object.assign({}, state, { loadingCount: state.loadingCount + 1 }) 62 | } 63 | case ActionNames.FETCH_FINISH: { 64 | return Object.assign({}, state, { loadingCount: state.loadingCount - 1 }) 65 | } 66 | default: 67 | return state 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-typescript-redux", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "postinstall": "sh ./moveToPublic.sh", 7 | "build": "webpack --config ./config/webpack.config.dev.js", 8 | "watch": "webpack --config ./config/webpack.config.dev.js --watch", 9 | "build:prod": "webpack --config ./config/webpack.config.prod.js", 10 | "test:unit": "karma start karma.conf.js", 11 | "test:all": "karma start karma.conf.js **/*.spec.tsx **/*.spec.ts", 12 | "server": "node ./config/dev-server.js", 13 | "lint:unit": "eslint", 14 | "lint:all": "eslint 'src/**/*.ts?(x)'", 15 | "format:all": "prettier --config ./prettier.config.js --write 'src/**/*.ts?(x)'", 16 | "format:unit": "prettier --config ./prettier.config.js --write" 17 | }, 18 | "devDependencies": { 19 | "@types/enzyme": "3.10.3", 20 | "@types/enzyme-adapter-react-16": "1.0.5", 21 | "@types/fetch-mock": "7.3.1", 22 | "@types/jasmine": "3.4.0", 23 | "@types/lodash": "4.14.136", 24 | "@types/react": "16.9.1", 25 | "@types/react-dom": "16.8.5", 26 | "@types/react-redux": "7.1.1", 27 | "@types/react-router-dom": "4.3.4", 28 | "@typescript-eslint/eslint-plugin": "1.13.0", 29 | "@typescript-eslint/parser": "1.13.0", 30 | "css-loader": "3.2.0", 31 | "enzyme": "3.10.0", 32 | "enzyme-adapter-react-16": "1.14.0", 33 | "eslint": "6.1.0", 34 | "eslint-config-prettier": "6.0.0", 35 | "eslint-plugin-prettier": "3.1.0", 36 | "eslint-plugin-react": "7.14.3", 37 | "express": "4.17.1", 38 | "fetch-mock": "7.3.9", 39 | "html-webpack-plugin": "3.2.0", 40 | "jasmine-core": "3.4.0", 41 | "karma": "4.2.0", 42 | "karma-chrome-launcher": "3.0.0", 43 | "karma-jasmine": "2.0.1", 44 | "karma-mocha-reporter": "2.2.5", 45 | "karma-webpack": "4.0.2", 46 | "multer": "1.4.2", 47 | "node-sass": "4.12.0", 48 | "prettier": "1.18.2", 49 | "react-test-renderer": "16.9.0", 50 | "sass-loader": "7.2.0", 51 | "style-loader": "1.0.0", 52 | "ts-loader": "6.0.4", 53 | "typescript": "3.5.3", 54 | "webpack": "4.39.1", 55 | "webpack-cli": "3.3.6" 56 | }, 57 | "dependencies": { 58 | "lodash": "4.17.15", 59 | "react": "16.9.0", 60 | "react-dnd": "9.3.4", 61 | "react-dnd-html5-backend": "9.3.4", 62 | "react-dom": "16.9.0", 63 | "react-redux": "7.1.0", 64 | "react-router-dom": "5.0.1", 65 | "redux": "4.0.4" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/dnd/Card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useRef } from 'react' 3 | import { DragSourceMonitor, DropTargetMonitor, useDrag, useDrop } from 'react-dnd' 4 | import { XYCoord } from 'dnd-core' 5 | 6 | const ItemTypes = { 7 | CARD: 'card' 8 | } 9 | 10 | interface Props { 11 | index: number 12 | id: number 13 | text: string 14 | moveCard: (dragIndex: number, hoverIndex: number) => void 15 | } 16 | 17 | export default function CardA(props: Props) { 18 | const ref = useRef(null) 19 | const [{ isDragging }, drag] = useDrag({ 20 | item: { 21 | id: props.id, 22 | index: props.index, 23 | type: ItemTypes.CARD 24 | }, 25 | collect: (monitor: DragSourceMonitor) => ({ 26 | isDragging: monitor.isDragging() 27 | }) 28 | }) 29 | 30 | const [, drop] = useDrop({ 31 | accept: ItemTypes.CARD, 32 | hover: (item: any, monitor: DropTargetMonitor) => { 33 | if (!ref.current) { 34 | return 35 | } 36 | const dragIndex = item.index 37 | const hoverIndex = props.index 38 | 39 | // Don't replace items with themselves 40 | if (dragIndex === hoverIndex) { 41 | return 42 | } 43 | 44 | // Determine rectangle on screen 45 | const hoverBoundingRect = ref.current!.getBoundingClientRect() 46 | 47 | // Get vertical middle 48 | const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2 49 | 50 | // Determine mouse position 51 | const clientOffset = monitor.getClientOffset() 52 | 53 | // Get pixels to the top 54 | const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top 55 | 56 | // Only perform the move when the mouse has crossed half of the items height 57 | // When dragging downwards, only move when the cursor is below 50% 58 | // When dragging upwards, only move when the cursor is above 50% 59 | 60 | // Dragging downwards 61 | if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { 62 | return 63 | } 64 | 65 | // Dragging upwards 66 | if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { 67 | return 68 | } 69 | 70 | // Time to actually perform the action 71 | props.moveCard(dragIndex, hoverIndex) 72 | 73 | // Note: we're mutating the monitor item here! 74 | // Generally it's better to avoid mutations, 75 | // but it's good here for the sake of performance 76 | // to avoid expensive index searches. 77 | item.index = hoverIndex 78 | } 79 | }) 80 | const opacity = isDragging ? 0 : 1 81 | 82 | drag(drop(ref)) 83 | return ( 84 |
    95 | {props.text} 96 |
    97 | ) 98 | } 99 | --------------------------------------------------------------------------------