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; +}