diff --git a/react-with-type-safe-flux/.gitignore b/react-with-type-safe-flux/.gitignore new file mode 100644 index 0000000..a983022 --- /dev/null +++ b/react-with-type-safe-flux/.gitignore @@ -0,0 +1,201 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +dist/ + +# Visual Studo 2015 cache/options directory +.vs/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# JUnit test results +test-results + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.[Cc]ache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +bower_components/ +typings/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt diff --git a/react-with-type-safe-flux/LICENSE b/react-with-type-safe-flux/LICENSE new file mode 100644 index 0000000..12a65bc --- /dev/null +++ b/react-with-type-safe-flux/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 John Reilly + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. + diff --git a/react-with-type-safe-flux/README.md b/react-with-type-safe-flux/README.md new file mode 100644 index 0000000..985bf13 --- /dev/null +++ b/react-with-type-safe-flux/README.md @@ -0,0 +1,49 @@ +# Type-Safe Flux Example + +Based on [ES6 + TypeScript + Babel + React + Karma: The Secret Recipe](../es6-babel-react-flux-karma), see more in corresponding [blog post](https://sameroom.io/blog/type-safe-flux-architecture-using-typescript/). + +## Getting started + +You'll need [node / npm](https://nodejs.org/) installed. To get up and running just enter: + +``` +npm install +npm run tsd install +npm run serve +``` + +This will: + +1. Download the npm packages you need +2. Download the typings from DefinitelyTyped that you need. +3. Compile the code and serve it up at [http://localhost:8080](http://localhost:8080) + +Now you need dev tools. There's a world of choice out there; there's [Atom](https://atom.io/), there's [VS Code](https://www.visualstudio.com/en-us/products/code-vs.aspx), there's [Sublime](http://www.sublimetext.com/). There's even something called [Visual Studio](http://www.visualstudio.com). It's all your choice really. + +For myself I've been using Atom combined with the mighty [atom-typescript package](https://atom.io/packages/atom-typescript). I advise you to give it a go. You won't look back. + +## I want to have an ASP.Net project and use Visual Studio + IIS Express to serve this instead + +If you drop this code into an empty Visual Studio ASP.Net project should should be good to go. You'll need this section in your `web.config` to ensure Visual Studio serves from the `dist` directory: + +``` + + + + + + + + + + + + +``` + +And rather than running `npm run serve` you'll want to use `npm run watch`. (This builds / watches your code / runs tests etc but does **not** spin up a web server.) + +Finally you'll want to set the following TypeScript options for your project + +- ECMAScript Version: ECMAScript 6 +- JSX compilation in TSX files: Preserve diff --git a/react-with-type-safe-flux/gulp/.eslintrc b/react-with-type-safe-flux/gulp/.eslintrc new file mode 100644 index 0000000..e91bbce --- /dev/null +++ b/react-with-type-safe-flux/gulp/.eslintrc @@ -0,0 +1,73 @@ +{ + "root": true, + "env": { + "commonjs": true, + }, + "rules": { + "camelcase": 2, + "comma-spacing": 1, + "consistent-return": 2, + "curly": [ 2, "all" ], + "dot-notation": [ + 2, + { "allowKeywords": true } + ], + "eol-last": 2, + "eqeqeq": 2, + "keyword-spacing": 2, + "new-cap": 2, + "new-parens": 2, + "no-alert": 2, + "no-array-constructor": 2, + "no-caller": 2, + "no-catch-shadow": 2, + "no-eval": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-extra-parens": [ 2, "functions" ], + "no-implied-eval": 2, + "no-iterator": 2, + "no-labels": 2, + "no-label-var": 2, + "no-lone-blocks": 2, + "no-loop-func": 2, + "no-multi-str": 2, + "no-native-reassign": 2, + "no-new": 2, + "no-new-func": 2, + "no-new-object": 2, + "no-new-wrappers": 2, + "no-octal-escape": 2, + "no-proto": 2, + "no-return-assign": 2, + "no-script-url": 2, + "no-sequences": 2, + "no-shadow": 2, + "no-shadow-restricted-names": 2, + "no-spaced-func": 2, + "no-trailing-spaces": 1, + "no-undef-init": 2, + "no-unused-expressions": 2, + "no-use-before-define": [ 2, "nofunc" ], + "no-with": 2, + "quotes": [ 1, "single" ], + "semi": 2, + "semi-spacing": [ + 2, + { + "before": false, + "after": true + } + ], + "space-infix-ops": 2, + "space-unary-ops": [ + 2, + { + "words": true, + "nonwords": false + } + ], + "strict": [ 2, "global" ], + "yoda": [ 2, "never" ] + } +} diff --git a/react-with-type-safe-flux/gulp/clean.js b/react-with-type-safe-flux/gulp/clean.js new file mode 100644 index 0000000..c51b4e4 --- /dev/null +++ b/react-with-type-safe-flux/gulp/clean.js @@ -0,0 +1,29 @@ +'use strict'; + +var del = require('del'); +var gutil = require('gulp-util'); +var fs = require('fs'); + +function run(done) { + fs.stat('./dist', function(err){ + if (err) { + // Never existed + done(); + } + else { + del(['./dist'], { force: true }) + .then(function(paths) { + gutil.log('Deleted files/folders:\n', paths.join('\n')); + done(); + }) + .catch(function(error) { + gutil.log('Problem deleting:\n', error); + done(); + }); + } + }); +} + +module.exports = { + run: function(done) { return run(done); } +}; diff --git a/react-with-type-safe-flux/gulp/inject.js b/react-with-type-safe-flux/gulp/inject.js new file mode 100644 index 0000000..e4133e5 --- /dev/null +++ b/react-with-type-safe-flux/gulp/inject.js @@ -0,0 +1,55 @@ +'use strict'; + +var gulp = require('gulp'); +var inject = require('gulp-inject'); +var glob = require('glob'); + +function injectIndex(options) { + function run() { + var target = gulp.src('./src/index.html'); + var sources = gulp.src([ + //'./dist/styles/main*.css', + './dist/scripts/vendor*.js', + './dist/scripts/main*.js' + ], { read: false }); + + return target + .pipe(inject(sources, { ignorePath: '/dist/', addRootSlash: false, removeTags: true })) + .pipe(gulp.dest('./dist')); + } + + var jsCssGlob = 'dist/**/*.{js,css}'; + + function checkForInitialFilesThenRun() { + glob(jsCssGlob, function (er, files) { + var filesWeNeed = ['dist/scripts/main', 'dist/scripts/vendor'/*, 'dist/styles/main'*/]; + + function fileIsPresent(fileWeNeed) { + return files.some(function(file) { + return file.indexOf(fileWeNeed) !== -1; + }); + } + + if (filesWeNeed.every(fileIsPresent)) { + run('initial build'); + } else { + checkForInitialFilesThenRun(); + } + }); + } + + checkForInitialFilesThenRun(); + + if (options.shouldWatch) { + gulp.watch(jsCssGlob, function(evt) { + if (evt.path && evt.type === 'changed') { + run(evt.path); + } + }); + } +} + +module.exports = { + build: function() { return injectIndex({ shouldWatch: false }); }, + watch: function() { return injectIndex({ shouldWatch: true }); } +}; diff --git a/react-with-type-safe-flux/gulp/staticFiles.js b/react-with-type-safe-flux/gulp/staticFiles.js new file mode 100644 index 0000000..20327f2 --- /dev/null +++ b/react-with-type-safe-flux/gulp/staticFiles.js @@ -0,0 +1,31 @@ +'use strict'; + +var gulp = require('gulp'); +var cache = require('gulp-cached'); + +var targets = [ + { description: 'INDEX', src: './src/index.html', dest: './dist' } +]; + +function copy(options) { + function run(target) { + gulp.src(target.src) + .pipe(cache(target.description)) + .pipe(gulp.dest(target.dest)); + } + + function watch(target) { + gulp.watch(target.src, function() { run(target); }); + } + + targets.forEach(run); + + if (options.shouldWatch) { + targets.forEach(watch); + } +} + +module.exports = { + build: function() { return copy({ shouldWatch: false }); }, + watch: function() { return copy({ shouldWatch: true }); } +}; diff --git a/react-with-type-safe-flux/gulp/tests.js b/react-with-type-safe-flux/gulp/tests.js new file mode 100644 index 0000000..4d6ba04 --- /dev/null +++ b/react-with-type-safe-flux/gulp/tests.js @@ -0,0 +1,26 @@ +'use strict'; + +var Server = require('karma').Server; +var path = require('path'); +var gutil = require('gulp-util'); + +module.exports = { + watch: function() { + // Documentation: https://karma-runner.github.io/0.13/dev/public-api.html + var karmaConfig = { + configFile: path.join(__dirname, '../karma.conf.js'), + singleRun: false, + + // Fancy runner + plugins: ['karma-webpack', 'karma-jasmine', 'karma-mocha-reporter', /*'karma-junit-reporter', 'karma-coverage', */'karma-sourcemap-loader', 'karma-phantomjs-launcher'], + reporters: ['mocha'] + }; + + new Server(karmaConfig, karmaCompleted).start(); + + function karmaCompleted(exitCode) { + gutil.log('Karma has exited with:', exitCode); + process.exit(exitCode); + } + } +}; diff --git a/react-with-type-safe-flux/gulp/webpack.js b/react-with-type-safe-flux/gulp/webpack.js new file mode 100644 index 0000000..37c4ded --- /dev/null +++ b/react-with-type-safe-flux/gulp/webpack.js @@ -0,0 +1,91 @@ +'use strict'; + +var gulp = require('gulp'); +var gutil = require('gulp-util'); +var webpack = require('webpack'); +var WebpackNotifierPlugin = require('webpack-notifier'); +var webpackConfig = require('../webpack.config.js'); + +function buildProduction(done) { + // modify some webpack config options + var myProdConfig = Object.create(webpackConfig); + myProdConfig.output.filename = '[name].[hash].js'; + + myProdConfig.plugins = myProdConfig.plugins.concat( + new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'vendor.[hash].js' }), + new webpack.optimize.DedupePlugin(), + new webpack.optimize.UglifyJsPlugin() + ); + + // run webpack + webpack(myProdConfig, function(err, stats) { + if(err) { throw new gutil.PluginError('webpack:build', err); } + gutil.log('[webpack:build]', stats.toString({ + colors: true + })); + + if (done) { done(); } + }); +} + +function createDevCompiler() { + // modify some webpack config options + var myDevConfig = Object.create(webpackConfig); + myDevConfig.devtool = 'inline-source-map'; + myDevConfig.debug = true; + + myDevConfig.plugins = myDevConfig.plugins.concat( + new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'vendor.js' }), + new WebpackNotifierPlugin({ title: 'Webpack build', excludeWarnings: true }) + ); + + // create a single instance of the compiler to allow caching + return webpack(myDevConfig); +} + +function buildDevelopment(done, devCompiler) { + // run webpack + devCompiler.run(function(err, stats) { + if(err) { throw new gutil.PluginError('webpack:build-dev', err); } + gutil.log('[webpack:build-dev]', stats.toString({ + chunks: false, + colors: true + })); + + if (done) { done(); } + }); +} + + +function bundle(options) { + var devCompiler; + + function build(done) { + if (options.shouldWatch) { + buildDevelopment(done, devCompiler); + } else { + buildProduction(done); + } + } + + if (options.shouldWatch) { + devCompiler = createDevCompiler(); + + gulp.watch('src/**/*', function() { build(); }); + } + + return new Promise(function(resolve, reject) { + build(function (err) { + if (err) { + reject(err); + } else { + resolve('webpack built'); + } + }); + }); +} + +module.exports = { + build: function() { return bundle({ shouldWatch: false }); }, + watch: function() { return bundle({ shouldWatch: true }); } +}; diff --git a/react-with-type-safe-flux/gulpFile.js b/react-with-type-safe-flux/gulpFile.js new file mode 100644 index 0000000..b7b3abe --- /dev/null +++ b/react-with-type-safe-flux/gulpFile.js @@ -0,0 +1,66 @@ +/* eslint-disable no-var, strict, prefer-arrow-callback */ +'use strict'; + +var gulp = require('gulp'); +var gutil = require('gulp-util'); +var eslint = require('gulp-eslint'); +var webpack = require('./gulp/webpack'); +var staticFiles = require('./gulp/staticFiles'); +var tests = require('./gulp/tests'); +var clean = require('./gulp/clean'); +var inject = require('./gulp/inject'); + +var lintSrcs = ['./gulp/**/*.js']; + +gulp.task('delete-dist', function (done) { + clean.run(done); +}); + +gulp.task('build-process.env.NODE_ENV', function () { + process.env.NODE_ENV = 'production'; +}); + +gulp.task('build-js', ['delete-dist', 'build-process.env.NODE_ENV'], function(done) { + webpack.build().then(function() { done(); }); +}); + +gulp.task('build-other', ['delete-dist', 'build-process.env.NODE_ENV'], function() { + staticFiles.build(); +}); + +gulp.task('build', ['build-js', 'build-other', 'lint'], function () { + inject.build(); +}); + +gulp.task('lint', function () { + return gulp.src(lintSrcs) + .pipe(eslint()) + .pipe(eslint.format()); +}); + +gulp.task('watch', ['delete-dist'], function(done) { + process.env.NODE_ENV = 'development'; + Promise.all([ + webpack.watch()//, + //less.watch() + ]).then(function() { + gutil.log('Now that initial assets (js and css) are generated inject will start...'); + inject.watch(); + done(); + }).catch(function(error) { + gutil.log('Problem generating initial assets (js and css)', error); + }); + + gulp.watch(lintSrcs, ['lint']); + staticFiles.watch(); + tests.watch(); +}); + +gulp.task('watch-and-serve', ['watch'], function() { + // local as not required for build + var express = require('express') + var app = express() + + app.use(express.static('dist', {'index': 'index.html'})) + app.listen(8080); +}); diff --git a/react-with-type-safe-flux/karma.conf.js b/react-with-type-safe-flux/karma.conf.js new file mode 100644 index 0000000..10ffba7 --- /dev/null +++ b/react-with-type-safe-flux/karma.conf.js @@ -0,0 +1,68 @@ +/* eslint-disable no-var, strict */ +'use strict'; + +var webpackConfig = require('./webpack.config.js'); + +module.exports = function(config) { + // Documentation: https://karma-runner.github.io/0.13/config/configuration-file.html + config.set({ + browsers: [ 'PhantomJS' ], + + files: [ + 'test/import-babel-polyfill.js', // This ensures we have the es6 shims in place from babel + 'test/**/*.tests.ts', + 'test/**/*.tests.tsx' + ], + + port: 9876, + + frameworks: [ 'jasmine' ], + + logLevel: config.LOG_INFO, //config.LOG_DEBUG + + preprocessors: { + 'test/import-babel-polyfill.js': [ 'webpack', 'sourcemap' ], + 'src/**/*.{ts,tsx}': [ 'webpack', 'sourcemap' ], + 'test/**/*.tests.{ts,tsx}': [ 'webpack', 'sourcemap' ] + }, + + webpack: { + devtool: 'eval-source-map', //'inline-source-map', + debug: true, + module: webpackConfig.module, + resolve: webpackConfig.resolve + }, + + webpackMiddleware: { + quiet: true, + stats: { + colors: true + } + }, + + // reporter options + mochaReporter: { + colors: { + success: 'bgGreen', + info: 'cyan', + warning: 'bgBlue', + error: 'bgRed' + } + }, + + // the default configuration + junitReporter: { + outputDir: 'test-results', // results will be saved as $outputDir/$browserName.xml + outputFile: undefined, // if included, results will be saved as $outputDir/$browserName/$outputFile + suite: '' + }, + + coverageReporter: { + reporters:[ + //{type: 'html', dir:'coverage/'}, // https://github.com/karma-runner/karma-coverage/issues/123 + {type: 'text'}, + {type: 'text-summary'} + ], + } + }); +}; diff --git a/react-with-type-safe-flux/package.json b/react-with-type-safe-flux/package.json new file mode 100644 index 0000000..74ed7af --- /dev/null +++ b/react-with-type-safe-flux/package.json @@ -0,0 +1,77 @@ +{ + "name": "es6-babel-react-flux-karma", + "version": "1.0.0", + "description": "ES6 + TypeScript + Babel + React + Karma: The Secret Recipe", + "main": "index.js", + "scripts": { + "tsd": "tsd", + "test": "karma start --reporters mocha,junit --single-run --browsers PhantomJS", + "serve": "gulp watch-and-serve", + "watch": "gulp watch", + "build": "gulp build" + }, + "bin": { + "tsd": "./node_modules/tsd/build/cli.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/typescriptsamples.git" + }, + "keywords": [ + "React", + "Flux", + "ES6", + "typescript" + ], + "author": "John Reilly", + "license": "MIT", + "bugs": { + "url": "https://github.com/microsoft/typescriptsamples/issues" + }, + "homepage": "https://github.com/Microsoft/TypeScriptSamples/tree/master/es6-babel-react-flux-karma#readme", + "devDependencies": { + "babel": "^6.0.0", + "babel-core": "^6.0.0", + "babel-loader": "^6.0.0", + "babel-polyfill": "^6.0.0", + "babel-preset-es2015": "^6.0.0", + "babel-preset-react": "^6.0.0", + "del": "^2.0.2", + "eslint": "^2.0.0", + "express": "^4.13.3", + "flux": "^2.0.3", + "glob": "^7.0.0", + "gulp": "^3.9.0", + "gulp-autoprefixer": "^3.1.0", + "gulp-cached": "^1.1.0", + "gulp-cssmin": "^0.1.7", + "gulp-eslint": "^2.0.0", + "gulp-if": "^2.0.0", + "gulp-inject": "^3.0.0", + "gulp-notify": "^2.2.0", + "gulp-sourcemaps": "^1.5.2", + "gulp-streamify": "1.0.2", + "gulp-uglify": "^1.2.0", + "gulp-util": "^3.0.6", + "jasmine-core": "^2.3.4", + "karma": "^0.13.10", + "karma-coverage": "^0.5.2", + "karma-jasmine": "^0.3.6", + "karma-junit-reporter": "^0.3.7", + "karma-mocha-reporter": "^1.1.1", + "karma-phantomjs-launcher": "^1.0.0", + "karma-phantomjs-shim": "^1.1.1", + "karma-sourcemap-loader": "^0.3.6", + "karma-webpack": "^1.7.0", + "phantomjs": "^2.1.3", + "phantomjs-prebuilt": "^2.1.4", + "react": "^0.14.3", + "react-addons-test-utils": "^0.14.3", + "react-dom": "^0.14.3", + "ts-loader": "^0.8.1", + "tsd": "^0.6.5", + "typescript": "^1.8.0", + "webpack": "^1.12.2", + "webpack-notifier": "^1.2.1" + } +} diff --git a/react-with-type-safe-flux/src/actions/GreetingActions.ts b/react-with-type-safe-flux/src/actions/GreetingActions.ts new file mode 100644 index 0000000..a4cfc7e --- /dev/null +++ b/react-with-type-safe-flux/src/actions/GreetingActions.ts @@ -0,0 +1,18 @@ +import {TypedEvent, AppDispatcher} from '../dispatcher/AppDispatcher'; +import GreetingActionTypes from '../constants/action-types/GreetingActionTypes'; + +export class AddGreetingEvent extends TypedEvent {} +export class NewGreetingChanged extends TypedEvent {} +export class RemoveGreeting extends TypedEvent {} + +export function addGreeting(newGreeting: string) { + AppDispatcher.dispatch(new AddGreetingEvent(newGreeting)); +} + +export function newGreetingChanged(newGreeting: string) { + AppDispatcher.dispatch(new NewGreetingChanged(newGreeting)); +} + +export function removeGreeting(greetingToRemove: string) { + AppDispatcher.dispatch(new RemoveGreeting(greetingToRemove)); +} diff --git a/react-with-type-safe-flux/src/components/App.tsx b/react-with-type-safe-flux/src/components/App.tsx new file mode 100644 index 0000000..7146ac7 --- /dev/null +++ b/react-with-type-safe-flux/src/components/App.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import GreetingStore from '../stores/GreetingStore'; +import * as GreetingActions from '../actions/GreetingActions'; +import GreetingState from '../types/GreetingState'; +import WhoToGreet from './WhoToGreet'; +import Greeting from './Greeting'; + +class App extends React.Component<{}, GreetingState> { + constructor(props: {}) { + super(props); + this.state = this._getStateFromStores(); + } + + componentWillMount() { + GreetingStore.addChangeListener(this._onChange); + } + + componentWillUnmount() { + GreetingStore.removeChangeListener(this._onChange); + } + + render() { + const { greetings, newGreeting } = this.state; + return ( +
+

Hello People!

+ + + + { greetings.map((g, index) => ) } +
+ ); + } + + _onChange = () => { + this.setState(this._getStateFromStores()); + } + + _getStateFromStores() { + return GreetingStore.getState(); + } +} + +export default App; diff --git a/react-with-type-safe-flux/src/components/Greeting.tsx b/react-with-type-safe-flux/src/components/Greeting.tsx new file mode 100644 index 0000000..7d1502b --- /dev/null +++ b/react-with-type-safe-flux/src/components/Greeting.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import * as GreetingActions from '../actions/GreetingActions'; + +interface Props { + key: number; + targetOfGreeting: string; +} + +class Greeting extends React.Component { + constructor(props: Props) { + super(props); + } + + static propTypes: React.ValidationMap = { + targetOfGreeting: React.PropTypes.string.isRequired + } + + render() { + return ( +

+ Hello { this.props.targetOfGreeting }! + + +

+ ); + } + + _onClick = (event: React.MouseEvent) => { + GreetingActions.removeGreeting(this.props.targetOfGreeting); + } +} + +export default Greeting; diff --git a/react-with-type-safe-flux/src/components/WhoToGreet.tsx b/react-with-type-safe-flux/src/components/WhoToGreet.tsx new file mode 100644 index 0000000..6712746 --- /dev/null +++ b/react-with-type-safe-flux/src/components/WhoToGreet.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import * as GreetingActions from '../actions/GreetingActions'; + +interface Props { + newGreeting: string; +} + +class WhoToGreet extends React.Component { + constructor(props: Props) { + super(props); + } + + static propTypes: React.ValidationMap = { + newGreeting: React.PropTypes.string.isRequired + } + + render() { + return ( +
+
+ + +
+
+ ); + } + + get _preventSubmission() { + return !this.props.newGreeting; + } + + _handleNewGreetingChange = (event: React.FormEvent) => { + const newGreeting = (event.target as HTMLInputElement).value; + GreetingActions.newGreetingChanged(newGreeting); + } + + _onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!this._preventSubmission) { + GreetingActions.addGreeting(this.props.newGreeting); + } + } +} + +export default WhoToGreet; diff --git a/react-with-type-safe-flux/src/constants/action-types/GreetingActionTypes.ts b/react-with-type-safe-flux/src/constants/action-types/GreetingActionTypes.ts new file mode 100644 index 0000000..ef9fda4 --- /dev/null +++ b/react-with-type-safe-flux/src/constants/action-types/GreetingActionTypes.ts @@ -0,0 +1,7 @@ +const GreetingActionTypes = { + ADD_GREETING: 'GreetingActionTypes.ADD_GREETING', + REMOVE_GREETING: 'GreetingActionTypes.REMOVE_GREETING', + NEW_GREETING_CHANGED: 'GreetingActionTypes.NEW_GREETING_CHANGED' +}; + +export default GreetingActionTypes; diff --git a/react-with-type-safe-flux/src/dispatcher/AppDispatcher.ts b/react-with-type-safe-flux/src/dispatcher/AppDispatcher.ts new file mode 100644 index 0000000..9dfa556 --- /dev/null +++ b/react-with-type-safe-flux/src/dispatcher/AppDispatcher.ts @@ -0,0 +1,11 @@ +import { Dispatcher } from 'flux'; + +export class TypedEvent

{ + constructor(public payload: P) {} +} + +export type Event = TypedEvent; + +const dispatcherInstance: Flux.Dispatcher = new Dispatcher(); + +export const AppDispatcher = dispatcherInstance; diff --git a/react-with-type-safe-flux/src/index.html b/react-with-type-safe-flux/src/index.html new file mode 100644 index 0000000..c5c150d --- /dev/null +++ b/react-with-type-safe-flux/src/index.html @@ -0,0 +1,19 @@ + + + + + + + + ES6 + Babel + React + Flux + Karma: The Secret Recipe + + + + + + +

+ + + + diff --git a/react-with-type-safe-flux/src/main.tsx b/react-with-type-safe-flux/src/main.tsx new file mode 100644 index 0000000..1e14a73 --- /dev/null +++ b/react-with-type-safe-flux/src/main.tsx @@ -0,0 +1,6 @@ +import 'babel-polyfill'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import App from './components/App'; + +ReactDOM.render(, document.getElementById('content')); diff --git a/react-with-type-safe-flux/src/stores/FluxStore.ts b/react-with-type-safe-flux/src/stores/FluxStore.ts new file mode 100644 index 0000000..0aca6c4 --- /dev/null +++ b/react-with-type-safe-flux/src/stores/FluxStore.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from 'events'; +import { Event } from '../dispatcher/AppDispatcher'; + +const CHANGE_EVENT = 'change'; + +class FluxStore { + _changed: boolean; + _emitter: EventEmitter; + dispatchToken: string; + _dispatcher: Flux.Dispatcher; + _cleanStateFn: () => TState; + _state: TState; + + constructor(dispatcher: Flux.Dispatcher, protected _onDispatch: (action: Event) => void, cleanStateFn: () => TState) { + this._emitter = new EventEmitter(); + this._changed = false; + this._dispatcher = dispatcher; + this.dispatchToken = dispatcher.register((payload: Event) => { + this._invokeOnDispatch(payload); + }); + + this._cleanStateFn = cleanStateFn; + this._state = this._cleanStateFn(); + } + + /** + * Is idempotent per dispatched event + */ + emitChange() { + this._changed = true; + } + + hasChanged() { return this._changed; } + + addChangeListener(callback: () => void) { + this._emitter.on(CHANGE_EVENT, callback); + } + + removeChangeListener(callback: () => void) { + this._emitter.removeListener(CHANGE_EVENT, callback); + } + + _cleanState() { + this._changed = false; + this._state = this._cleanStateFn(); + } + + _invokeOnDispatch(payload: Event) { + this._changed = false; + this._onDispatch(payload); + if (this._changed) { + this._emitter.emit(CHANGE_EVENT); + } + } +} + +export default FluxStore; diff --git a/react-with-type-safe-flux/src/stores/GreetingStore.ts b/react-with-type-safe-flux/src/stores/GreetingStore.ts new file mode 100644 index 0000000..08840b9 --- /dev/null +++ b/react-with-type-safe-flux/src/stores/GreetingStore.ts @@ -0,0 +1,37 @@ +import FluxStore from './FluxStore'; +import GreetingActionTypes from '../constants/action-types/GreetingActionTypes'; +import {Event, AppDispatcher} from '../dispatcher/AppDispatcher'; +import GreetingState from '../types/GreetingState'; +import { AddGreetingEvent, RemoveGreeting, NewGreetingChanged } from '../actions/GreetingActions'; + +class GreeterStore extends FluxStore { + constructor(dispatcher: Flux.Dispatcher) { + const onDispatch = (action: Event) => { + if (action instanceof AddGreetingEvent) { + const {payload} = action; + this._state.newGreeting = ''; + this._state.greetings = this._state.greetings.concat(payload); + this.emitChange(); + } else if (action instanceof RemoveGreeting) { + const {payload} = action; + this._state.greetings = this._state.greetings.filter(g => g !== payload); + this.emitChange(); + } else if (action instanceof NewGreetingChanged) { + const {payload} = action; + this._state.newGreeting = payload; + this.emitChange(); + } + } + super(dispatcher, onDispatch, () => ({ + greetings: [], + newGreeting: '' + })); + } + + getState() { + return this._state + } +} + +const greeterStoreInstance = new GreeterStore(AppDispatcher); +export default greeterStoreInstance; diff --git a/react-with-type-safe-flux/src/tsconfig.json b/react-with-type-safe-flux/src/tsconfig.json new file mode 100644 index 0000000..f116b8b --- /dev/null +++ b/react-with-type-safe-flux/src/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compileOnSave": false, + "filesGlob": [ + "../typings/**/*.*.ts", + "!../typings/jasmine/jasmine.d.ts", + "!../typings/react/react-addons-test-utils.d.ts", + "**/*.{ts,tsx}" + ], + "compilerOptions": { + "jsx": "preserve", + "target": "es6", + "noImplicitAny": true, + "removeComments": false, + "preserveConstEnums": true, + "sourceMap": true + }, + "files": [ + "../typings/flux/flux.d.ts", + "../typings/node/node.d.ts", + "../typings/react/react-dom.d.ts", + "../typings/react/react.d.ts", + "../typings/tsd.d.ts", + "actions/GreetingActions.ts", + "components/App.tsx", + "components/Greeting.tsx", + "components/WhoToGreet.tsx", + "constants/action-types/GreetingActionTypes.ts", + "dispatcher/AppDispatcher.ts", + "main.tsx", + "stores/FluxStore.ts", + "stores/GreetingStore.ts", + "types/GreetingState.ts" + ], + "exclude": [], + "atom": { + "rewriteTsconfig": true + } +} diff --git a/react-with-type-safe-flux/src/types/GreetingState.ts b/react-with-type-safe-flux/src/types/GreetingState.ts new file mode 100644 index 0000000..656b201 --- /dev/null +++ b/react-with-type-safe-flux/src/types/GreetingState.ts @@ -0,0 +1,6 @@ +interface GreetingState { + greetings: string[]; + newGreeting: string; +} + +export default GreetingState; diff --git a/react-with-type-safe-flux/test/components/App.tests.tsx b/react-with-type-safe-flux/test/components/App.tests.tsx new file mode 100644 index 0000000..1530e64 --- /dev/null +++ b/react-with-type-safe-flux/test/components/App.tests.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import * as TestUtils from 'react-addons-test-utils'; +import App from '../../src/components/App'; +import WhoToGreet from '../../src/components/WhoToGreet'; +import Greeting from '../../src/components/Greeting'; +import GreetingStore from '../../src/stores/GreetingStore'; + +describe('App', () => { + it('renders expected HTML', () => { + const app = render({ greetings: ['James'], newGreeting: 'Benjamin' }); + expect(app).toEqual( +
+

Hello People!

+ + + + { [ + + ] } +
+ ); + }); + + function render(state) { + const shallowRenderer = TestUtils.createRenderer(); + spyOn(GreetingStore, 'getState').and.returnValue(state); + + shallowRenderer.render(); + return shallowRenderer.getRenderOutput(); + } +}); diff --git a/react-with-type-safe-flux/test/components/Greeting.tests.tsx b/react-with-type-safe-flux/test/components/Greeting.tests.tsx new file mode 100644 index 0000000..9ea44b4 --- /dev/null +++ b/react-with-type-safe-flux/test/components/Greeting.tests.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as TestUtils from 'react-addons-test-utils'; +import Greeting from '../../src/components/Greeting'; +import * as GreetingActions from '../../src/actions/GreetingActions'; + +describe('Greeting', () => { + let handleSelectionChangeSpy: jasmine.Spy; + beforeEach(() => { + handleSelectionChangeSpy = jasmine.createSpy('handleSelectionChange'); + }); + + it('given a targetOfGreeting of \'James\' it renders a p containing a greeting and a remove button', () => { + const targetOfGreeting = 'James'; + + const p = render({ targetOfGreeting }); + expect(p.type).toBe('p'); + expect(p.props.children[0]).toBe('Hello '); + expect(p.props.children[1]).toBe('James'); + expect(p.props.children[2]).toBe('!'); + + const [ , , , button ] = p.props.children; + + expect(button.type).toBe('button'); + expect(button.props.className).toBe('btn btn-default btn-danger'); + expect(button.props.children).toBe('Remove'); + }); + + it('button onClick triggers an removeGreeting action', () => { + const targetOfGreeting = 'Benjamin'; + const p = render({ targetOfGreeting }); + const [ , , , button ] = p.props.children; + spyOn(GreetingActions, 'removeGreeting'); + + button.props.onClick(); + + expect(GreetingActions.removeGreeting).toHaveBeenCalledWith(targetOfGreeting); + }); + + function render({ targetOfGreeting }) { + const shallowRenderer = TestUtils.createRenderer(); + shallowRenderer.render(); + return shallowRenderer.getRenderOutput(); + } +}); diff --git a/react-with-type-safe-flux/test/components/WhoToGreet.tests.tsx b/react-with-type-safe-flux/test/components/WhoToGreet.tests.tsx new file mode 100644 index 0000000..e514ec3 --- /dev/null +++ b/react-with-type-safe-flux/test/components/WhoToGreet.tests.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import * as TestUtils from 'react-addons-test-utils'; +import WhoToGreet from '../../src/components/WhoToGreet'; +import * as GreetingActions from '../../src/actions/GreetingActions'; + +describe('WhoToGreet', () => { + let handleSelectionChangeSpy: jasmine.Spy; + beforeEach(() => { + handleSelectionChangeSpy = jasmine.createSpy('handleSelectionChange'); + }); + + it('given a newGreeting then it renders a form containing an input containing that text and an add button', () => { + const newGreeting = 'James'; + + const form = render({ newGreeting }); + expect(form.type).toBe('form'); + expect(form.props.role).toBe('form'); + + const formGroup = form.props.children; + expect(formGroup.type).toBe('div'); + expect(formGroup.props.className).toBe('form-group'); + + const [ input, button ] = formGroup.props.children; + + expect(input.type).toBe('input'); + expect(input.props.type).toBe('text'); + expect(input.props.className).toBe('form-control'); + expect(input.props.placeholder).toBe('Who would you like to greet?'); + expect(input.props.value).toBe(newGreeting); + + expect(button.type).toBe('button'); + expect(button.props.type).toBe('submit'); + expect(button.props.className).toBe('btn btn-default btn-primary'); + expect(button.props.disabled).toBe(false); + expect(button.props.children).toBe('Add greeting'); + }); + + it('input onChange triggers a newGreetingChanged action', () => { + const newGreeting = 'Benjamin'; + const form = render({ newGreeting }); + const formGroup = form.props.children; + const [ input ] = formGroup.props.children; + spyOn(GreetingActions, 'newGreetingChanged'); + + input.props.onChange({ target: { value: newGreeting }}); + + expect(GreetingActions.newGreetingChanged).toHaveBeenCalledWith(newGreeting); + }); + + it('button onClick triggers an addGreeting action', () => { + const newGreeting = 'Benjamin'; + const form = render({ newGreeting }); + const formGroup = form.props.children; + const [ , button ] = formGroup.props.children; + spyOn(GreetingActions, 'addGreeting'); + + button.props.onClick({ preventDefault: () => {} }); + + expect(GreetingActions.addGreeting).toHaveBeenCalledWith(newGreeting); + }); + + function render({ newGreeting }) { + const shallowRenderer = TestUtils.createRenderer(); + shallowRenderer.render(); + return shallowRenderer.getRenderOutput(); + } +}); diff --git a/react-with-type-safe-flux/test/import-babel-polyfill.js b/react-with-type-safe-flux/test/import-babel-polyfill.js new file mode 100644 index 0000000..b012711 --- /dev/null +++ b/react-with-type-safe-flux/test/import-babel-polyfill.js @@ -0,0 +1 @@ +import 'babel-polyfill'; diff --git a/react-with-type-safe-flux/test/stores/GreetingStore.tests.ts b/react-with-type-safe-flux/test/stores/GreetingStore.tests.ts new file mode 100644 index 0000000..f617a0d --- /dev/null +++ b/react-with-type-safe-flux/test/stores/GreetingStore.tests.ts @@ -0,0 +1,45 @@ +import GreetingStore from '../../src/stores/GreetingStore'; +import GreetingActionTypes from '../../src/constants/action-types/GreetingActionTypes'; +import { AddGreetingEvent, RemoveGreeting, NewGreetingChanged } from '../../src/actions/GreetingActions'; + +const registeredCallback = GreetingStore._onDispatch.bind(GreetingStore); + +describe('GreetingStore', () => { + beforeEach(() => { + GreetingStore._cleanState(); + }); + + it('given no actions, newGreeting should be an empty string and greetings should be an empty array', () => { + const { greetings, newGreeting } = GreetingStore.getState(); + + expect(greetings).toEqual([]); + expect(newGreeting).toBe(''); + }); + + it('given an ADD_GREETING action with a newGreeting of \'Benjamin\', the newGreeting should be an empty string and greetings should contain \'Benjamin\'', () => { + [new AddGreetingEvent('Benjamin')].forEach(registeredCallback); + + const { greetings, newGreeting } = GreetingStore.getState(); + + expect(greetings.find(g => g === 'Benjamin')).toBeTruthy(); + expect(newGreeting).toBe(''); + }); + + it('given an REMOVE_GREETING action with a greetingToRemove of \'Benjamin\', the state greetings should be an empty array', () => { + [new AddGreetingEvent('Benjamin'), new RemoveGreeting('Benjamin')].forEach(registeredCallback); + + const { greetings } = GreetingStore.getState(); + + expect(greetings.length).toBe(0); + expect(greetings.find(g => g === 'Benjamin')).toBeFalsy(); + }); + + it('given a NEW_GREETING_CHANGED action with a newGreeting of \'Benjamin\', the state newGreeting should be \'Benjamin\'', () => { + [new NewGreetingChanged('Benjamin')].forEach(registeredCallback); + + const { newGreeting } = GreetingStore.getState(); + + expect(newGreeting).toEqual('Benjamin'); + }); + +}); diff --git a/react-with-type-safe-flux/test/tsconfig.json b/react-with-type-safe-flux/test/tsconfig.json new file mode 100644 index 0000000..368d9d5 --- /dev/null +++ b/react-with-type-safe-flux/test/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compileOnSave": false, + "filesGlob": [ + "**/*.{ts,tsx}", + "../typings/**/*.*.ts" + ], + "compilerOptions": { + "jsx": "preserve", + "target": "es6", + "module": "commonjs", + "noImplicitAny": false, + "suppressImplicitAnyIndexErrors": true, + "removeComments": false, + "preserveConstEnums": true, + "sourceMap": true + }, + "files": [ + "components/App.tests.tsx", + "components/Greeting.tests.tsx", + "components/WhoToGreet.tests.tsx", + "stores/GreetingStore.tests.ts", + "../typings/flux/flux.d.ts", + "../typings/jasmine/jasmine.d.ts", + "../typings/node/node.d.ts", + "../typings/react/react-addons-test-utils.d.ts", + "../typings/react/react-dom.d.ts", + "../typings/react/react.d.ts", + "../typings/tsd.d.ts" + ], + "exclude": [], + "atom": { + "rewriteTsconfig": true + } +} diff --git a/react-with-type-safe-flux/tsd.json b/react-with-type-safe-flux/tsd.json new file mode 100644 index 0000000..2716a22 --- /dev/null +++ b/react-with-type-safe-flux/tsd.json @@ -0,0 +1,27 @@ +{ + "version": "v4", + "repo": "borisyankov/DefinitelyTyped", + "ref": "master", + "path": "typings", + "bundle": "typings/tsd.d.ts", + "installed": { + "jasmine/jasmine.d.ts": { + "commit": "bcd5761826eb567876c197ccc6a87c4d05731054" + }, + "flux/flux.d.ts": { + "commit": "bcd5761826eb567876c197ccc6a87c4d05731054" + }, + "node/node.d.ts": { + "commit": "bcd5761826eb567876c197ccc6a87c4d05731054" + }, + "react/react.d.ts": { + "commit": "bcd5761826eb567876c197ccc6a87c4d05731054" + }, + "react/react-dom.d.ts": { + "commit": "bcd5761826eb567876c197ccc6a87c4d05731054" + }, + "react/react-addons-test-utils.d.ts": { + "commit": "bcd5761826eb567876c197ccc6a87c4d05731054" + } + } +} diff --git a/react-with-type-safe-flux/webpack.config.js b/react-with-type-safe-flux/webpack.config.js new file mode 100644 index 0000000..70d955b --- /dev/null +++ b/react-with-type-safe-flux/webpack.config.js @@ -0,0 +1,42 @@ +/* eslint-disable no-var, strict, prefer-arrow-callback */ +'use strict'; + +var path = require('path'); + +module.exports = { + cache: true, + entry: { + main: './src/main.tsx', + vendor: [ + 'babel-polyfill', + 'events', + 'flux', + 'react' + ] + }, + output: { + path: path.resolve(__dirname, './dist/scripts'), + filename: '[name].js', + chunkFilename: '[chunkhash].js' + }, + module: { + loaders: [{ + test: /\.ts(x?)$/, + exclude: /node_modules/, + loader: 'babel-loader?presets[]=es2015&presets[]=react!ts-loader' + }, { + test: /\.js$/, + exclude: /node_modules/, + loader: 'babel', + query: { + presets: ['es2015', 'react'] + } + }] + }, + plugins: [ + ], + resolve: { + // Add `.ts` and `.tsx` as a resolvable extension. + extensions: ['', '.webpack.js', '.web.js', '.ts', '.tsx', '.js'] + }, +};