diff --git a/step2-09/README.md b/step2-09/README.md
index f9361fe..c0f2981 100644
--- a/step2-09/README.md
+++ b/step2-09/README.md
@@ -1,3 +1,19 @@
# Step 2.9
-actions with service calls
+Redux Thunk middleware for actions with service calls. The documentation is here:
+
+https://github.com/reduxjs/redux-thunk
+
+Action creators are a natural place to put service calls. Redux thunk middleware passes in the `dispatch()` and `getState()` from the store into the action creators. This allows the action creator itself to dispatch different actions in between async side effects. Combined with the async / await syntax, coding service calls is a cinch!
+
+Most of the time, in a single-page app, we apply **optimistic UI updates**. We can update the UI before the network call completes so the UI feels more responsive.
+
+# Exercise
+
+1. open up `exercise/src/service/index.ts` and study the signature of the functions to call the service such as the `add()` function
+
+2. open `exercise/src/actions/index.ts` and fill in the missing content inside `actionsWithService`
+
+- note that the `complete` and `clear` functions require you to write your own wrapper function
+
+3. open `exercise/src/index.tsx` and follow the instructions in the TODO comment to make the app prepopulate with data from the service.
diff --git a/step2-09/index.html b/step2-09/demo/index.html
similarity index 100%
rename from step2-09/index.html
rename to step2-09/demo/index.html
diff --git a/step2-09/src/actions/index.ts b/step2-09/demo/src/actions/index.ts
similarity index 100%
rename from step2-09/src/actions/index.ts
rename to step2-09/demo/src/actions/index.ts
diff --git a/step2-09/src/components/TodoApp.tsx b/step2-09/demo/src/components/TodoApp.tsx
similarity index 100%
rename from step2-09/src/components/TodoApp.tsx
rename to step2-09/demo/src/components/TodoApp.tsx
diff --git a/step2-09/src/components/TodoFooter.tsx b/step2-09/demo/src/components/TodoFooter.tsx
similarity index 100%
rename from step2-09/src/components/TodoFooter.tsx
rename to step2-09/demo/src/components/TodoFooter.tsx
diff --git a/step2-09/src/components/TodoHeader.tsx b/step2-09/demo/src/components/TodoHeader.tsx
similarity index 100%
rename from step2-09/src/components/TodoHeader.tsx
rename to step2-09/demo/src/components/TodoHeader.tsx
diff --git a/step2-09/src/components/TodoList.tsx b/step2-09/demo/src/components/TodoList.tsx
similarity index 100%
rename from step2-09/src/components/TodoList.tsx
rename to step2-09/demo/src/components/TodoList.tsx
diff --git a/step2-09/src/components/TodoListItem.tsx b/step2-09/demo/src/components/TodoListItem.tsx
similarity index 100%
rename from step2-09/src/components/TodoListItem.tsx
rename to step2-09/demo/src/components/TodoListItem.tsx
diff --git a/step2-09/src/index.tsx b/step2-09/demo/src/index.tsx
similarity index 100%
rename from step2-09/src/index.tsx
rename to step2-09/demo/src/index.tsx
diff --git a/step2-09/src/reducers/index.ts b/step2-09/demo/src/reducers/index.ts
similarity index 100%
rename from step2-09/src/reducers/index.ts
rename to step2-09/demo/src/reducers/index.ts
diff --git a/step2-09/src/reducers/pureFunctions.spec.ts b/step2-09/demo/src/reducers/pureFunctions.spec.ts
similarity index 100%
rename from step2-09/src/reducers/pureFunctions.spec.ts
rename to step2-09/demo/src/reducers/pureFunctions.spec.ts
diff --git a/step2-09/src/reducers/pureFunctions.ts b/step2-09/demo/src/reducers/pureFunctions.ts
similarity index 100%
rename from step2-09/src/reducers/pureFunctions.ts
rename to step2-09/demo/src/reducers/pureFunctions.ts
diff --git a/step2-09/src/service/index.ts b/step2-09/demo/src/service/index.ts
similarity index 89%
rename from step2-09/src/service/index.ts
rename to step2-09/demo/src/service/index.ts
index b2c2481..4c46320 100644
--- a/step2-09/src/service/index.ts
+++ b/step2-09/demo/src/service/index.ts
@@ -11,7 +11,7 @@ export async function add(id: string, todo: TodoItem) {
return await response.json();
}
-export async function edit(id: string, todo: TodoItem) {
+export async function update(id: string, todo: TodoItem) {
const response = await fetch(`${HOST}/todos/${id}`, {
method: 'put',
headers: { 'content-type': 'application/json' },
@@ -37,7 +37,7 @@ export async function getAll() {
return await response.json();
}
-export async function editBulk(todos: Store['todos']) {
+export async function updateAll(todos: Store['todos']) {
const response = await fetch(`${HOST}/todos`, {
method: 'post',
headers: { 'content-type': 'application/json' },
diff --git a/step2-09/src/store/index.ts b/step2-09/demo/src/store/index.ts
similarity index 100%
rename from step2-09/src/store/index.ts
rename to step2-09/demo/src/store/index.ts
diff --git a/step2-09/exercise/index.html b/step2-09/exercise/index.html
new file mode 100644
index 0000000..454cef5
--- /dev/null
+++ b/step2-09/exercise/index.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/step2-09/exercise/src/actions/index.ts b/step2-09/exercise/src/actions/index.ts
new file mode 100644
index 0000000..9a988ce
--- /dev/null
+++ b/step2-09/exercise/src/actions/index.ts
@@ -0,0 +1,51 @@
+import uuid from 'uuid/v4';
+import { Store } from '../store';
+import * as service from '../service';
+
+export const actions = {
+ addTodo: (label: string) => ({ type: 'addTodo', id: uuid(), label }),
+ remove: (id: string) => ({ type: 'remove', id }),
+ complete: (id: string) => ({ type: 'complete', id }),
+ clear: () => ({ type: 'clear' }),
+ setFilter: (filter: string) => ({ type: 'setFilter', filter })
+};
+
+export const actionsWithService = {
+ addTodo: (label: string) => {
+ return async (dispatch: any, getState: () => Store) => {
+ // Replace the return true with:
+ // 1. first call the actions.addTodo() function
+ // 2. store the resultant id
+ // 3. dispatch the action message generated by that call
+ // 4. pass the id and the todo from the state into the service call service.add()
+ return true;
+ };
+ },
+
+ remove: (id: string) => {
+ return async (dispatch: any, getState: () => Store) => {
+ // Replace the return true with:
+ // 1. dispatch a remove action with the id
+ // 2. await on the call to the service.remove()
+ return true;
+ };
+ },
+
+ complete: (id: string) => {
+ // ** Now it's your turn to write the thunk! **
+ // Replace the return Promise.resolve(true) with:
+ // 1. return an async function with the arguments of dispatch and getState
+ // 2. dispatch a remove action with the id
+ // 3. await on the call to the service.update()
+ return Promise.resolve(true);
+ },
+
+ clear: () => {
+ // ** Write your own thunk again! **
+ // Replace the return Promise.resolve(true) with:
+ // 1. return an async function with the arguments of dispatch and getState
+ // 2. dispatch a clear action
+ // 3. await on the call to the service.updateAll()
+ return Promise.resolve(true);
+ }
+};
diff --git a/step2-09/exercise/src/components/TodoApp.tsx b/step2-09/exercise/src/components/TodoApp.tsx
new file mode 100644
index 0000000..ef8f05b
--- /dev/null
+++ b/step2-09/exercise/src/components/TodoApp.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Stack, Customizer, mergeStyles, getTheme } from 'office-ui-fabric-react';
+import { TodoFooter } from './TodoFooter';
+import { TodoHeader } from './TodoHeader';
+import { TodoList } from './TodoList';
+import { Store } from '../store';
+import { FluentCustomizations } from '@uifabric/fluent-theme';
+
+const className = mergeStyles({
+ padding: 25,
+ ...getTheme().effects.elevation4
+});
+
+export class TodoApp extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ todos: {},
+ filter: 'all'
+ };
+ }
+ render() {
+ const { filter, todos } = this.state;
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/step2-09/exercise/src/components/TodoFooter.tsx b/step2-09/exercise/src/components/TodoFooter.tsx
new file mode 100644
index 0000000..1a80be6
--- /dev/null
+++ b/step2-09/exercise/src/components/TodoFooter.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { Text } from '@uifabric/experiments';
+import { Stack } from 'office-ui-fabric-react';
+import { Store } from '../store';
+import { DefaultButton } from 'office-ui-fabric-react';
+import { connect } from 'react-redux';
+import { actionsWithService } from '../actions';
+
+interface TodoFooterProps {
+ clear: () => void;
+ todos: Store['todos'];
+}
+
+const TodoFooter = (props: TodoFooterProps) => {
+ const itemCount = Object.keys(props.todos).filter(id => !props.todos[id].completed).length;
+
+ return (
+
+
+ {itemCount} item{itemCount > 1 ? 's' : ''} left
+
+ props.clear()}>Clear Completed
+
+ );
+};
+
+function mapStateToProps(state: Store) {
+ return { ...state };
+}
+
+function mapDispatchToProps(dispatch: any) {
+ return {
+ clear: () => dispatch(actionsWithService.clear())
+ };
+}
+
+const component = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(TodoFooter);
+
+export { component as TodoFooter };
diff --git a/step2-09/exercise/src/components/TodoHeader.tsx b/step2-09/exercise/src/components/TodoHeader.tsx
new file mode 100644
index 0000000..9d69117
--- /dev/null
+++ b/step2-09/exercise/src/components/TodoHeader.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { Text } from '@uifabric/experiments';
+import { Stack } from 'office-ui-fabric-react';
+import { Pivot, PivotItem, TextField, PrimaryButton } from 'office-ui-fabric-react';
+import { FilterTypes, Store } from '../store';
+import { actionsWithService, actions } from '../actions';
+import { connect } from 'react-redux';
+
+interface TodoHeaderProps {
+ addTodo: (label: string) => void;
+ setFilter: (filter: FilterTypes) => void;
+ filter: FilterTypes;
+}
+
+interface TodoHeaderState {
+ labelInput: string;
+}
+
+class TodoHeader extends React.Component {
+ constructor(props: TodoHeaderProps) {
+ super(props);
+ this.state = { labelInput: undefined };
+ }
+
+ render() {
+ return (
+
+
+ todos
+
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
+
+ );
+ }
+
+ private onAdd = () => {
+ this.props.addTodo(this.state.labelInput);
+ this.setState({ labelInput: undefined });
+ };
+
+ private onChange = (evt: React.FormEvent, newValue: string) => {
+ this.setState({ labelInput: newValue });
+ };
+
+ private onFilter = (item: PivotItem) => {
+ this.props.setFilter(item.props.headerText as FilterTypes);
+ };
+}
+
+function mapStateToProps(state: Store) {
+ return { ...state };
+}
+
+function mapDispatchToProps(dispatch: any) {
+ return {
+ addTodo: (label: string) => dispatch(actionsWithService.addTodo(label)),
+ setFilter: (filter: FilterTypes) => dispatch(actions.setFilter(filter))
+ };
+}
+
+const component = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(TodoHeader);
+
+export { component as TodoHeader };
diff --git a/step2-09/exercise/src/components/TodoList.tsx b/step2-09/exercise/src/components/TodoList.tsx
new file mode 100644
index 0000000..2f33d89
--- /dev/null
+++ b/step2-09/exercise/src/components/TodoList.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { Stack } from 'office-ui-fabric-react';
+import { TodoListItem } from './TodoListItem';
+import { Store, FilterTypes } from '../store';
+import { connect } from 'react-redux';
+
+interface TodoListProps {
+ todos: Store['todos'];
+ filter: FilterTypes;
+}
+
+const TodoList = (props: TodoListProps) => {
+ const { filter, todos } = props;
+ const filteredTodos = Object.keys(todos).filter(id => {
+ return filter === 'all' || (filter === 'completed' && todos[id].completed) || (filter === 'active' && !todos[id].completed);
+ });
+
+ return (
+
+ {filteredTodos.map(id => (
+
+ ))}
+
+ );
+};
+
+function mapStateToProps(state: Store) {
+ return { ...state };
+}
+
+function mapDispatchToProps(dispatch: any) {
+ return {};
+}
+
+const component = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(TodoList);
+
+export { component as TodoList };
diff --git a/step2-09/exercise/src/components/TodoListItem.tsx b/step2-09/exercise/src/components/TodoListItem.tsx
new file mode 100644
index 0000000..a2f8a7b
--- /dev/null
+++ b/step2-09/exercise/src/components/TodoListItem.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react';
+import { Store } from '../store';
+import { connect } from 'react-redux';
+import { actionsWithService } from '../actions';
+
+interface TodoListItemProps {
+ id: string;
+ todos: Store['todos'];
+ remove: (id: string) => void;
+ complete: (id: string) => void;
+}
+
+class TodoListItem extends React.Component {
+ render() {
+ const { todos, id, complete, remove } = this.props;
+ const item = todos[id];
+
+ return (
+
+ complete(id)} />
+
+ remove(id)} />
+
+
+ );
+ }
+}
+
+function mapStateToProps({ todos }: Store) {
+ return {
+ todos
+ };
+}
+
+function mapDispatchToProps(dispatch: any) {
+ return {
+ remove: (id: string) => dispatch(actionsWithService.remove(id)),
+ complete: (id: string) => dispatch(actionsWithService.complete(id))
+ };
+}
+
+const component = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(TodoListItem);
+
+export { component as TodoListItem };
diff --git a/step2-09/exercise/src/index.tsx b/step2-09/exercise/src/index.tsx
new file mode 100644
index 0000000..4b46d99
--- /dev/null
+++ b/step2-09/exercise/src/index.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { reducer } from './reducers';
+import { applyMiddleware, createStore, compose } from 'redux';
+import thunk from 'redux-thunk';
+import { Provider } from 'react-redux';
+import { TodoApp } from './components/TodoApp';
+import { initializeIcons } from '@uifabric/icons';
+import { Store, FilterTypes } from './store';
+import * as service from './service';
+
+/* Goop for making the Redux dev tool to work */
+declare var window: any;
+const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+function createStoreWithDevTool(reducer, initialStore?: Store) {
+ return createStore(reducer, initialStore, composeEnhancers(applyMiddleware(thunk)));
+}
+
+(async () => {
+ // TODO: to make the store pre-populate with data from the service,
+ // replace the todos value below with a call to "await service.getAll()"
+ const preloadStore = {
+ todos: {},
+ filter: 'all' as FilterTypes
+ };
+
+ const store = createStoreWithDevTool(reducer, preloadStore);
+
+ initializeIcons();
+
+ ReactDOM.render(
+
+
+ ,
+ document.getElementById('app')
+ );
+})();
diff --git a/step2-09/exercise/src/reducers/index.ts b/step2-09/exercise/src/reducers/index.ts
new file mode 100644
index 0000000..7d3103f
--- /dev/null
+++ b/step2-09/exercise/src/reducers/index.ts
@@ -0,0 +1,35 @@
+import { Store } from '../store';
+import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
+import { combineReducers } from 'redux';
+
+function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] {
+ switch (action.type) {
+ case 'addTodo':
+ return addTodo(state, action.id, action.label);
+
+ case 'remove':
+ return remove(state, action.id);
+
+ case 'clear':
+ return clear(state);
+
+ case 'complete':
+ return complete(state, action.id);
+ }
+
+ return state;
+}
+
+function filterReducer(state: Store['filter'] = 'all', action: any): Store['filter'] {
+ switch (action.type) {
+ case 'setFilter':
+ return setFilter(state, action.filter);
+ }
+
+ return state;
+}
+
+export const reducer = combineReducers({
+ todos: todoReducer,
+ filter: filterReducer
+});
diff --git a/step2-09/exercise/src/reducers/pureFunctions.spec.ts b/step2-09/exercise/src/reducers/pureFunctions.spec.ts
new file mode 100644
index 0000000..b3815cf
--- /dev/null
+++ b/step2-09/exercise/src/reducers/pureFunctions.spec.ts
@@ -0,0 +1,29 @@
+import { addTodo, complete } from './pureFunctions';
+import { Store } from '../store';
+
+describe('TodoApp reducers', () => {
+ it('can add an item', () => {
+ const state = {};
+
+ const newState = addTodo(state, '0', 'item1');
+
+ const keys = Object.keys(newState);
+
+ expect(newState).not.toBe(state);
+ expect(keys.length).toBe(1);
+ expect(newState[keys[0]].label).toBe('item1');
+ expect(newState[keys[0]].completed).toBeFalsy();
+ });
+
+ it('can complete an item', () => {
+ const state = {};
+
+ let newState = addTodo(state, '0', 'item1');
+
+ const key = Object.keys(newState)[0];
+
+ newState = complete(newState, key);
+
+ expect(newState[key].completed).toBeTruthy();
+ });
+});
diff --git a/step2-09/exercise/src/reducers/pureFunctions.ts b/step2-09/exercise/src/reducers/pureFunctions.ts
new file mode 100644
index 0000000..dc53164
--- /dev/null
+++ b/step2-09/exercise/src/reducers/pureFunctions.ts
@@ -0,0 +1,40 @@
+import { Store, FilterTypes } from '../store';
+
+export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] {
+ return { ...state, [id]: { label, completed: false } };
+}
+
+export function edit(state: Store['todos'], id: string, label: string): Store['todos'] {
+ return { ...state, [id]: { ...state[id], label } };
+}
+
+export function remove(state: Store['todos'], id: string) {
+ const newTodos = { ...state };
+
+ delete newTodos[id];
+
+ return newTodos;
+}
+
+export function complete(state: Store['todos'], id: string) {
+ const newTodos = { ...state };
+ newTodos[id].completed = !newTodos[id].completed;
+
+ return newTodos;
+}
+
+export function clear(state: Store['todos']) {
+ const newTodos = { ...state };
+
+ Object.keys(state.todos).forEach(key => {
+ if (state.todos[key].completed) {
+ delete newTodos[key];
+ }
+ });
+
+ return newTodos;
+}
+
+export function setFilter(state: Store['filter'], filter: FilterTypes) {
+ return filter;
+}
diff --git a/step2-09/exercise/src/service/index.ts b/step2-09/exercise/src/service/index.ts
new file mode 100644
index 0000000..4c46320
--- /dev/null
+++ b/step2-09/exercise/src/service/index.ts
@@ -0,0 +1,48 @@
+import { TodoItem, Store } from '../store';
+const HOST = 'http://localhost:3000';
+
+export async function add(id: string, todo: TodoItem) {
+ const response = await fetch(`${HOST}/todos/${id}`, {
+ method: 'post',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(todo)
+ });
+
+ return await response.json();
+}
+
+export async function update(id: string, todo: TodoItem) {
+ const response = await fetch(`${HOST}/todos/${id}`, {
+ method: 'put',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(todo)
+ });
+
+ return await response.json();
+}
+
+export async function remove(id: string) {
+ const response = await fetch(`${HOST}/todos/${id}`, {
+ method: 'delete'
+ });
+
+ return await response.json();
+}
+
+export async function getAll() {
+ const response = await fetch(`${HOST}/todos`, {
+ method: 'get'
+ });
+
+ return await response.json();
+}
+
+export async function updateAll(todos: Store['todos']) {
+ const response = await fetch(`${HOST}/todos`, {
+ method: 'post',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(todos)
+ });
+
+ return await response.json();
+}
diff --git a/step2-09/exercise/src/store/index.ts b/step2-09/exercise/src/store/index.ts
new file mode 100644
index 0000000..221b5f4
--- /dev/null
+++ b/step2-09/exercise/src/store/index.ts
@@ -0,0 +1,14 @@
+export type FilterTypes = 'all' | 'active' | 'completed';
+
+export interface TodoItem {
+ label: string;
+ completed: boolean;
+}
+
+export interface Store {
+ todos: {
+ [id: string]: TodoItem;
+ };
+
+ filter: FilterTypes;
+}