= {
+ 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']
+ },
+};