diff --git a/package-lock.json b/package-lock.json index e594056..04e94d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2170,6 +2170,11 @@ "integrity": "sha512-wv7IRqCGsL7WGKB8gPvrl+++HlFM9kxAM6jL1EXNPNTshEJYilMkbfS2SnuHha77uosp/YVK0wAp2jmlBzn1tg==", "dev": true }, + "curriable": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/curriable/-/curriable-1.2.5.tgz", + "integrity": "sha512-hQwrkCn8DNiCw5CG8OS0td2wfpCDtxo1dmrVYxCDWUBHQPkpAvN9RqBVbmC64oSQaBqPQD2SOCXcTWH1zXe2mA==" + }, "cyclist": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", @@ -3122,6 +3127,11 @@ "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", "dev": true }, + "fast-equals": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-1.6.2.tgz", + "integrity": "sha512-6FiKwHkrHIqdvo9I92yzCR/zuWE6iy5ldcOszStPbmo7Zzj3OoVeng++GE//JqO4i6JQ4vH/BVAfCmUCki+C3g==" + }, "fast-json-stable-stringify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", @@ -4518,6 +4528,14 @@ "postcss": "^7.0.5" } }, + "identitate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/identitate/-/identitate-1.0.1.tgz", + "integrity": "sha512-xnDJ0JYhiZjBDuJRKbHoVzj5yP9FhATxLyUYswQyPdnJrwzGVBqS6DOmvKJi1lk7P+4dkL+hhUhuOZIcOUtG5A==", + "requires": { + "pathington": "^1.0.1" + } + }, "ieee754": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", @@ -6376,8 +6394,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json3": { "version": "3.3.2", @@ -7626,6 +7643,11 @@ } } }, + "pathington": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/pathington/-/pathington-1.1.7.tgz", + "integrity": "sha512-JxzhUzagDfNIOm4qqwQqP3rWeo7rNNOfIahy4n+3GTEdwXLqw5cJHUR0soSopQtNEv763lzxb6eA2xBllpR8zw==" + }, "pbkdf2": { "version": "3.0.17", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", @@ -8168,6 +8190,33 @@ "symbol-observable": "^1.2.0" } }, + "redux-devtools-extension": { + "version": "2.13.8", + "resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz", + "integrity": "sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg==" + }, + "redux-immutable-state-invariant": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/redux-immutable-state-invariant/-/redux-immutable-state-invariant-2.1.0.tgz", + "integrity": "sha512-3czbDKs35FwiBRsx/3KabUk5zSOoTXC+cgVofGkpBNv3jQcqIe5JrHcF5AmVt7B/4hyJ8MijBIpCJ8cife6yJg==", + "requires": { + "invariant": "^2.1.0", + "json-stringify-safe": "^5.0.1" + } + }, + "redux-starter-kit": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/redux-starter-kit/-/redux-starter-kit-0.4.3.tgz", + "integrity": "sha512-T3HXtwEGyFLCy63QlZEM95rDzTQ5BKbBhhTINNVFD1yWgHBKtgN3tXw4/sX9+Ve1Y8mAtXbAhep/E7I0YVvZPg==", + "requires": { + "immer": "^1.10.5", + "redux": "^4.0.0", + "redux-devtools-extension": "^2.13.7", + "redux-immutable-state-invariant": "^2.1.0", + "redux-thunk": "^2.2.0", + "selectorator": "^4.0.1" + } + }, "redux-thunk": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", @@ -8386,6 +8435,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "reselect": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz", + "integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==" + }, "resolve": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.10.0.tgz", @@ -8533,6 +8587,17 @@ "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=", "dev": true }, + "selectorator": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/selectorator/-/selectorator-4.0.3.tgz", + "integrity": "sha512-A8+paRhzTab4Qm/38RAVnCgEZFbpn5xIWLyTCDqvyU3Obhmo94RS6UK1H00bVH7+U609sOhqbFJha09POsWURA==", + "requires": { + "fast-equals": "^1.2.1", + "identitate": "^1.0.0", + "reselect": "^4.0.0", + "unchanged": "^2.0.1" + } + }, "selfsigned": { "version": "1.10.4", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.4.tgz", @@ -9754,6 +9819,15 @@ } } }, + "unchanged": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unchanged/-/unchanged-2.1.0.tgz", + "integrity": "sha512-CYVU0Mz35N821Pjxf+iG6JMPx/Yb6wStUq8uuhR52/+AU3vMiueOTrJ6u1vBKYSke7a75FAi2/jCdQkXcHaNGQ==", + "requires": { + "curriable": "^1.2.4", + "pathington": "^1.1.5" + } + }, "undefsafe": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.2.tgz", diff --git a/package.json b/package.json index 20d185e..b3cc97e 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "react-dom": "^16.7.0", "react-redux": "^6.0.0", "redux-thunk": "^2.3.0", + "redux-starter-kit": "^0.4.3", "redux": "^4.0.1" } } diff --git a/step2-06/src/actions/index.ts b/step2-06/src/actions/index.ts index 07003c7..4a067b9 100644 --- a/step2-06/src/actions/index.ts +++ b/step2-06/src/actions/index.ts @@ -1,3 +1,8 @@ -export const addTodo = (label: string) => ({ type: 'addTodo', label }); -export const remove = (id: string) => ({ type: 'remove', id }); -export const complete = (id: string) => ({ type: 'complete', id }); +import uuid from 'uuid/v4'; + +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' }) +}; diff --git a/step2-06/src/index.tsx b/step2-06/src/index.tsx index 56f4eab..e4ca608 100644 --- a/step2-06/src/index.tsx +++ b/step2-06/src/index.tsx @@ -1,6 +1,6 @@ import { reducer } from './reducers'; import { createStore, compose } from 'redux'; -import { addTodo } from './actions'; +import { actions } from './actions'; /* Goop for making the Redux dev tool to work */ declare var window: any; @@ -13,7 +13,7 @@ const store = createStoreWithDevTool(reducer, {}); console.log(store.getState()); -store.dispatch(addTodo('hello')); -store.dispatch(addTodo('world')); +store.dispatch(actions.addTodo('hello')); +store.dispatch(actions.addTodo('world')); console.log(store.getState()); diff --git a/step2-06/src/reducers/index.ts b/step2-06/src/reducers/index.ts index 946dbb1..6eed3c5 100644 --- a/step2-06/src/reducers/index.ts +++ b/step2-06/src/reducers/index.ts @@ -1,17 +1,27 @@ import { Store } from '../store'; -import { addTodo, remove, complete } from './pureFunctions'; +import { addTodo, remove, complete, clear } from './pureFunctions'; -export function reducer(state: Store, action: any): Store { +function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] { switch (action.type) { case 'addTodo': - return addTodo(state, action.label); + 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; } + +export function reducer(state: Store, action: any): Store { + return { + todos: todoReducer(state.todos, action), + filter: 'all' + }; +} diff --git a/step2-06/src/reducers/pureFunctions.spec.ts b/step2-06/src/reducers/pureFunctions.spec.ts index e151959..b3815cf 100644 --- a/step2-06/src/reducers/pureFunctions.spec.ts +++ b/step2-06/src/reducers/pureFunctions.spec.ts @@ -1,20 +1,29 @@ -import { addTodo } from './pureFunctions'; +import { addTodo, complete } from './pureFunctions'; import { Store } from '../store'; describe('TodoApp reducers', () => { it('can add an item', () => { - const state = { - todos: {}, - filter: 'all' - }; + const state = {}; - const newState = addTodo(state, 'item1'); + const newState = addTodo(state, '0', 'item1'); - const keys = Object.keys(newState.todos); + const keys = Object.keys(newState); expect(newState).not.toBe(state); expect(keys.length).toBe(1); - expect(newState.todos[keys[0]].label).toBe('item1'); - expect(newState.todos[keys[0]].completed).toBeFalsy(); + 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-06/src/reducers/pureFunctions.ts b/step2-06/src/reducers/pureFunctions.ts index 6de4d2e..69e492a 100644 --- a/step2-06/src/reducers/pureFunctions.ts +++ b/step2-06/src/reducers/pureFunctions.ts @@ -1,34 +1,36 @@ -import { Store } from '../store'; +import { Store, FilterTypes } from '../store'; -let index = 0; - -export function addTodo(state: Store, label: string): Store { - const { todos } = state; - const id = index++; - - return { - ...state, - todos: { ...todos, [id]: { label, completed: false } } - }; +export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] { + return { ...state, [id]: { label, completed: false } }; } -export function remove(state: Store, id: string) { - const newTodos = { ...state.todos }; +export function remove(state: Store['todos'], id: string) { + const newTodos = { ...state }; delete newTodos[id]; - return { - ...state, - todos: newTodos - }; + return newTodos; } -export function complete(state: Store, id: string) { - const newTodos = { ...state.todos }; +export function complete(state: Store['todos'], id: string) { + const newTodos = { ...state }; newTodos[id].completed = !newTodos[id].completed; - return { - ...state, - todos: newTodos - }; + 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-07/src/actions/index.ts b/step2-07/src/actions/index.ts index f36dc87..4a067b9 100644 --- a/step2-07/src/actions/index.ts +++ b/step2-07/src/actions/index.ts @@ -1,4 +1,8 @@ -export const addTodo = (label: string) => ({ type: 'addTodo', label }); -export const remove = (id: string) => ({ type: 'remove', id }); -export const complete = (id: string) => ({ type: 'complete', id }); -export const clear = () => ({ type: 'clear' }); +import uuid from 'uuid/v4'; + +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' }) +}; diff --git a/step2-07/src/components/TodoFooter.tsx b/step2-07/src/components/TodoFooter.tsx index 7c23c44..4bf1f24 100644 --- a/step2-07/src/components/TodoFooter.tsx +++ b/step2-07/src/components/TodoFooter.tsx @@ -4,7 +4,7 @@ import { Stack } from 'office-ui-fabric-react'; import { Store } from '../store'; import { DefaultButton } from 'office-ui-fabric-react'; import { connect } from 'react-redux'; -import * as actions from '../actions'; +import { actions } from '../actions'; interface TodoFooterProps { clear: () => void; diff --git a/step2-07/src/components/TodoHeader.tsx b/step2-07/src/components/TodoHeader.tsx index 3124c99..c88342a 100644 --- a/step2-07/src/components/TodoHeader.tsx +++ b/step2-07/src/components/TodoHeader.tsx @@ -1,10 +1,10 @@ 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 * as actions from '../actions'; +import { TextField, PrimaryButton } from 'office-ui-fabric-react'; +import { Store } from '../store'; import { connect } from 'react-redux'; +import { actions } from '../actions'; interface TodoHeaderProps { addTodo: (label: string) => void; diff --git a/step2-07/src/components/TodoList.tsx b/step2-07/src/components/TodoList.tsx index 8bb4054..f9781ae 100644 --- a/step2-07/src/components/TodoList.tsx +++ b/step2-07/src/components/TodoList.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { Stack } from 'office-ui-fabric-react'; import { TodoListItem } from './TodoListItem'; -import { Store, FilterTypes } from '../store'; +import { Store } from '../store'; import { connect } from 'react-redux'; -import * as actions from '../actions'; interface TodoListProps { todos: Store['todos']; diff --git a/step2-07/src/components/TodoListItem.tsx b/step2-07/src/components/TodoListItem.tsx index 482b472..24ab756 100644 --- a/step2-07/src/components/TodoListItem.tsx +++ b/step2-07/src/components/TodoListItem.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react'; +import { Stack, Checkbox, IconButton } from 'office-ui-fabric-react'; import { Store } from '../store'; import { connect } from 'react-redux'; -import * as actions from '../actions'; +import { actions } from '../actions'; interface TodoListItemProps { id: string; diff --git a/step2-07/src/index.tsx b/step2-07/src/index.tsx index e207cb1..1528004 100644 --- a/step2-07/src/index.tsx +++ b/step2-07/src/index.tsx @@ -4,7 +4,7 @@ import { reducer } from './reducers'; import { createStore, compose } from 'redux'; import { Provider } from 'react-redux'; import { TodoApp } from './components/TodoApp'; -import { addTodo } from './actions'; +import { actions } from './actions'; import { initializeIcons } from '@uifabric/icons'; /* Goop for making the Redux dev tool to work */ @@ -16,8 +16,8 @@ function createStoreWithDevTool(reducer, initialStore) { const store = createStoreWithDevTool(reducer, {}); -store.dispatch(addTodo('hello')); -store.dispatch(addTodo('world')); +store.dispatch(actions.addTodo('hello')); +store.dispatch(actions.addTodo('world')); initializeIcons(); diff --git a/step2-07/src/reducers/index.ts b/step2-07/src/reducers/index.ts index 0054c0e..6eed3c5 100644 --- a/step2-07/src/reducers/index.ts +++ b/step2-07/src/reducers/index.ts @@ -1,10 +1,10 @@ import { Store } from '../store'; import { addTodo, remove, complete, clear } from './pureFunctions'; -export function reducer(state: Store, action: any): Store { +function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] { switch (action.type) { case 'addTodo': - return addTodo(state, action.label); + return addTodo(state, action.id, action.label); case 'remove': return remove(state, action.id); @@ -18,3 +18,10 @@ export function reducer(state: Store, action: any): Store { return state; } + +export function reducer(state: Store, action: any): Store { + return { + todos: todoReducer(state.todos, action), + filter: 'all' + }; +} diff --git a/step2-07/src/reducers/pureFunctions.spec.ts b/step2-07/src/reducers/pureFunctions.spec.ts index 282e951..b3815cf 100644 --- a/step2-07/src/reducers/pureFunctions.spec.ts +++ b/step2-07/src/reducers/pureFunctions.spec.ts @@ -3,33 +3,27 @@ import { Store } from '../store'; describe('TodoApp reducers', () => { it('can add an item', () => { - const state = { - todos: {}, - filter: 'all' - }; + const state = {}; - const newState = addTodo(state, 'item1'); + const newState = addTodo(state, '0', 'item1'); - const keys = Object.keys(newState.todos); + const keys = Object.keys(newState); expect(newState).not.toBe(state); expect(keys.length).toBe(1); - expect(newState.todos[keys[0]].label).toBe('item1'); - expect(newState.todos[keys[0]].completed).toBeFalsy(); + expect(newState[keys[0]].label).toBe('item1'); + expect(newState[keys[0]].completed).toBeFalsy(); }); it('can complete an item', () => { - const state = { - todos: {}, - filter: 'all' - }; + const state = {}; - let newState = addTodo(state, 'item1'); + let newState = addTodo(state, '0', 'item1'); - const key = Object.keys(newState.todos)[0]; + const key = Object.keys(newState)[0]; newState = complete(newState, key); - expect(newState.todos[key].completed).toBeTruthy(); + expect(newState[key].completed).toBeTruthy(); }); }); diff --git a/step2-07/src/reducers/pureFunctions.ts b/step2-07/src/reducers/pureFunctions.ts index 7b7c7fd..69e492a 100644 --- a/step2-07/src/reducers/pureFunctions.ts +++ b/step2-07/src/reducers/pureFunctions.ts @@ -1,49 +1,36 @@ -import { Store } from '../store'; +import { Store, FilterTypes } from '../store'; -let index = 0; - -export function addTodo(state: Store, label: string): Store { - const { todos } = state; - const id = index++; - - return { - ...state, - todos: { ...todos, [id]: { label, completed: false } } - }; +export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] { + return { ...state, [id]: { label, completed: false } }; } -export function remove(state: Store, id: string) { - const newTodos = { ...state.todos }; +export function remove(state: Store['todos'], id: string) { + const newTodos = { ...state }; delete newTodos[id]; - return { - ...state, - todos: newTodos - }; + return newTodos; } -export function complete(state: Store, id: string) { - const newTodos = { ...state.todos }; +export function complete(state: Store['todos'], id: string) { + const newTodos = { ...state }; newTodos[id].completed = !newTodos[id].completed; - return { - ...state, - todos: newTodos - }; + return newTodos; } -export function clear(state: Store) { - const newTodos = {}; +export function clear(state: Store['todos']) { + const newTodos = { ...state }; Object.keys(state.todos).forEach(key => { - if (!state.todos[key].completed) { - newTodos[key] = state.todos[key]; + if (state.todos[key].completed) { + delete newTodos[key]; } }); - return { - ...state, - todos: newTodos - }; + return newTodos; +} + +export function setFilter(state: Store['filter'], filter: FilterTypes) { + return filter; } diff --git a/step2-08/src/actions/index.ts b/step2-08/src/actions/index.ts index 05959f8..0b9c43a 100644 --- a/step2-08/src/actions/index.ts +++ b/step2-08/src/actions/index.ts @@ -1,5 +1,9 @@ -export const addTodo = (label: string) => ({ type: 'addTodo', label }); -export const remove = (id: string) => ({ type: 'remove', id }); -export const complete = (id: string) => ({ type: 'complete', id }); -export const clear = () => ({ type: 'clear' }); -export const setFilter = (filter: string) => ({ type: 'setFilter', filter }); +import uuid from 'uuid/v4'; + +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 }) +}; diff --git a/step2-08/src/components/TodoFooter.tsx b/step2-08/src/components/TodoFooter.tsx index 7c23c44..4bf1f24 100644 --- a/step2-08/src/components/TodoFooter.tsx +++ b/step2-08/src/components/TodoFooter.tsx @@ -4,7 +4,7 @@ import { Stack } from 'office-ui-fabric-react'; import { Store } from '../store'; import { DefaultButton } from 'office-ui-fabric-react'; import { connect } from 'react-redux'; -import * as actions from '../actions'; +import { actions } from '../actions'; interface TodoFooterProps { clear: () => void; diff --git a/step2-08/src/components/TodoHeader.tsx b/step2-08/src/components/TodoHeader.tsx index 4859517..af9dc77 100644 --- a/step2-08/src/components/TodoHeader.tsx +++ b/step2-08/src/components/TodoHeader.tsx @@ -3,7 +3,7 @@ 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 * as actions from '../actions'; +import { actions } from '../actions'; import { connect } from 'react-redux'; interface TodoHeaderProps { diff --git a/step2-08/src/components/TodoList.tsx b/step2-08/src/components/TodoList.tsx index cd0962f..2f33d89 100644 --- a/step2-08/src/components/TodoList.tsx +++ b/step2-08/src/components/TodoList.tsx @@ -3,7 +3,6 @@ import { Stack } from 'office-ui-fabric-react'; import { TodoListItem } from './TodoListItem'; import { Store, FilterTypes } from '../store'; import { connect } from 'react-redux'; -import * as actions from '../actions'; interface TodoListProps { todos: Store['todos']; diff --git a/step2-08/src/components/TodoListItem.tsx b/step2-08/src/components/TodoListItem.tsx index 482b472..24ab756 100644 --- a/step2-08/src/components/TodoListItem.tsx +++ b/step2-08/src/components/TodoListItem.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react'; +import { Stack, Checkbox, IconButton } from 'office-ui-fabric-react'; import { Store } from '../store'; import { connect } from 'react-redux'; -import * as actions from '../actions'; +import { actions } from '../actions'; interface TodoListItemProps { id: string; diff --git a/step2-08/src/index.tsx b/step2-08/src/index.tsx index 9767dfd..f5cf5a1 100644 --- a/step2-08/src/index.tsx +++ b/step2-08/src/index.tsx @@ -4,7 +4,7 @@ import { reducer } from './reducers'; import { createStore, compose } from 'redux'; import { Provider } from 'react-redux'; import { TodoApp } from './components/TodoApp'; -import { addTodo } from './actions'; +import { actions } from './actions'; import { initializeIcons } from '@uifabric/icons'; import { Store } from './store'; @@ -17,8 +17,8 @@ function createStoreWithDevTool(reducer, initialStore?: Store) { const store = createStoreWithDevTool(reducer); -store.dispatch(addTodo('hello')); -store.dispatch(addTodo('world')); +store.dispatch(actions.addTodo('hello')); +store.dispatch(actions.addTodo('world')); initializeIcons(); diff --git a/step2-08/src/reducers/index.ts b/step2-08/src/reducers/index.ts index 4ec7c95..7d3103f 100644 --- a/step2-08/src/reducers/index.ts +++ b/step2-08/src/reducers/index.ts @@ -5,7 +5,7 @@ import { combineReducers } from 'redux'; function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] { switch (action.type) { case 'addTodo': - return addTodo(state, action.label); + return addTodo(state, action.id, action.label); case 'remove': return remove(state, action.id); diff --git a/step2-08/src/reducers/pureFunctions.spec.ts b/step2-08/src/reducers/pureFunctions.spec.ts new file mode 100644 index 0000000..b3815cf --- /dev/null +++ b/step2-08/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-08/src/reducers/pureFunctions.ts b/step2-08/src/reducers/pureFunctions.ts index 0d6765f..69e492a 100644 --- a/step2-08/src/reducers/pureFunctions.ts +++ b/step2-08/src/reducers/pureFunctions.ts @@ -1,9 +1,6 @@ import { Store, FilterTypes } from '../store'; -let index = 0; - -export function addTodo(state: Store['todos'], label: string): Store['todos'] { - const id = index++; +export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] { return { ...state, [id]: { label, completed: false } }; } diff --git a/step2-09/README.md b/step2-09/README.md new file mode 100644 index 0000000..f9361fe --- /dev/null +++ b/step2-09/README.md @@ -0,0 +1,3 @@ +# Step 2.9 + +actions with service calls diff --git a/step2-09/index.html b/step2-09/index.html new file mode 100644 index 0000000..454cef5 --- /dev/null +++ b/step2-09/index.html @@ -0,0 +1,6 @@ + + + +
+ + diff --git a/step2-09/src/actions/index.ts b/step2-09/src/actions/index.ts new file mode 100644 index 0000000..a881f85 --- /dev/null +++ b/step2-09/src/actions/index.ts @@ -0,0 +1,43 @@ +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) => { + const addAction = actions.addTodo(label); + const id = addAction.id; + dispatch(addAction); + await service.add(id, getState().todos[id]); + }; + }, + + remove: (id: string) => { + return async (dispatch: any, getState: () => Store) => { + dispatch(actions.remove(id)); + await service.remove(id); + }; + }, + + complete: (id: string) => { + return async (dispatch: any, getState: () => Store) => { + dispatch(actions.complete(id)); + await service.edit(id, getState().todos[id]); + }; + }, + + clear: () => { + return async (dispatch: any, getState: () => Store) => { + dispatch(actions.clear()); + await service.editBulk(getState().todos); + }; + } +}; diff --git a/step2-09/src/components/TodoApp.tsx b/step2-09/src/components/TodoApp.tsx new file mode 100644 index 0000000..ef8f05b --- /dev/null +++ b/step2-09/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/src/components/TodoFooter.tsx b/step2-09/src/components/TodoFooter.tsx new file mode 100644 index 0000000..1a80be6 --- /dev/null +++ b/step2-09/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/src/components/TodoHeader.tsx b/step2-09/src/components/TodoHeader.tsx new file mode 100644 index 0000000..9d69117 --- /dev/null +++ b/step2-09/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/src/components/TodoList.tsx b/step2-09/src/components/TodoList.tsx new file mode 100644 index 0000000..2f33d89 --- /dev/null +++ b/step2-09/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/src/components/TodoListItem.tsx b/step2-09/src/components/TodoListItem.tsx new file mode 100644 index 0000000..a2f8a7b --- /dev/null +++ b/step2-09/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/src/index.tsx b/step2-09/src/index.tsx new file mode 100644 index 0000000..f5cf5a1 --- /dev/null +++ b/step2-09/src/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { reducer } from './reducers'; +import { createStore, compose } from 'redux'; +import { Provider } from 'react-redux'; +import { TodoApp } from './components/TodoApp'; +import { actions } from './actions'; +import { initializeIcons } from '@uifabric/icons'; +import { Store } from './store'; + +/* 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()); +} + +const store = createStoreWithDevTool(reducer); + +store.dispatch(actions.addTodo('hello')); +store.dispatch(actions.addTodo('world')); + +initializeIcons(); + +ReactDOM.render( + + + , + document.getElementById('app') +); diff --git a/step2-09/src/reducers/index.ts b/step2-09/src/reducers/index.ts new file mode 100644 index 0000000..7d3103f --- /dev/null +++ b/step2-09/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/src/reducers/pureFunctions.spec.ts b/step2-09/src/reducers/pureFunctions.spec.ts new file mode 100644 index 0000000..b3815cf --- /dev/null +++ b/step2-09/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/src/reducers/pureFunctions.ts b/step2-09/src/reducers/pureFunctions.ts new file mode 100644 index 0000000..dc53164 --- /dev/null +++ b/step2-09/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/src/service/index.ts b/step2-09/src/service/index.ts new file mode 100644 index 0000000..b2c2481 --- /dev/null +++ b/step2-09/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 edit(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 editBulk(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/src/store/index.ts b/step2-09/src/store/index.ts new file mode 100644 index 0000000..221b5f4 --- /dev/null +++ b/step2-09/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; +}