This commit is contained in:
Ken
2019-03-02 22:33:54 -08:00
parent 562d3bc20d
commit bdd92705df
78 changed files with 237 additions and 2523 deletions

View File

@@ -132,12 +132,9 @@
</div>
</li>
<li class="Tile Tile--numbered">
<div class="Tile-link">
<a target="_blank" href="./step1-01/" class="Tile-link">
Redux: Service Calls
<div class="Tile-links">
<a target="_blank" href="./step2-09/demo/">demo</a> | <a target="_blank" href="./step2-09/exercise/">exercise</a>
</div>
</div>
</a>
</li>
</ul>
</div>

View File

@@ -1,88 +1,48 @@
# Step 2.7: Connect Redux store to view (Demo)
# Step 2.7: Service calls (Demo)
[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/)
[Lessons](../)
Redux is currently the most popular Flux implementation, and the ecosystem of related libraries has grown as a result. This is one of the reasons why it is a very popular library within Microsoft products.
> Note: this step doesn't work with the live site on github.io. Clone the repo to try this step out.
Various GitHub users have collected "awesome lists" of tech and articles related to Redux. Here is [one such list](https://github.com/xgrommx/awesome-redux#react---a-javascript-library-for-building-user-interfaces), but it is literally impossible to list out all the related tech.
## `redux-thunk`: side effects inside action creators
In this step, we introduce but one useful library that works with Redux: [`react-redux`](https://react-redux.js.org/).
The [Redux Thunk](https://github.com/reduxjs/redux-thunk) middleware allows writing actions that make service calls.
## The official React Redux binding: `react-redux`
Remember those simple little action functions? They're called action creators. These little functions can be charged with superpowers to allow asynchronous side effects to happen while creating the messages. Asynchronous side effects include service calls against APIs.
That's right, Redux doesn't just work with React. It can also be used with Vue.js, Angular, and React Native, to name a few.
Action creators are a natural place to put service calls. Redux Thunk middleware passes `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!
### `<Provider>` component
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.
The store doesn't magically get passed to the views. It has to be supplied by a `react-redux` component called [`<Provider>`](https://react-redux.js.org/api/provider). A `<Provider>` can be placed anywhere, but it's best to just make it available at the root the app:
## Action creator with a thunk
```js
const store = createStore(reducers);
[What's a thunk?](https://daveceddia.com/what-is-a-thunk/) - it is a wrapper function that returns a function. What does it do? Let's find out!
const App = () => {
return (
<Provider store={store}>
<div>Hello World!</div>
</Provider>
);
};
```
### `connect()` higher-order function
`react-redux` provides a [`connect()`](https://react-redux.js.org/api/connect) function that turns the Redux store and dispatch functions into props for React components. The state and action dispatchers are passed along with a `<Provider>` component.
```js
const OldComponent = props => {
return <div>{props.foo}</div>;
};
const NewComponent = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
options
)(OldComponent);
```
`connect()` takes in a few functions that map portions of the state tree and dispatcher functions into props. It is a **higher-order function**, meaning that the return value of `connect()` is a function that decorates `OldComponent` into a `NewComponent` with all the mapped props.
Next we'll learn about how to write `mapStateToProps` and `mapDispatchToProps`. For demonstration purposes, we'll assume the store and the component props look like this:
This action creator just returns an object:
```ts
interface Store {
foo: string;
// and probably some other properties
}
interface ComponentProps {
foo: string;
addTodo: (label: string) => void;
function addTodo(label: string) {
return { type: 'addTodo', id: uuid(), label };
}
```
#### `mapStateToProps`
A [`mapStateToProps`](https://react-redux.js.org/api/connect#mapstatetoprops-state-ownprops-object) function uses the state tree as a parameter and selects portions of it that will be passed to the component as part of `props`. When values in the state tree change, the `mapStateToProps` function is called, and the new props are passed to the component (causing React to re-render it).
In order for us to make service calls, we need to supercharge this with the power of `redux-thunk`
```ts
function mapStateToProps(state: Store): Partial<ComponentProps> {
return {
foo: state.foo
function 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]);
};
}
```
#### `mapDispatchToProps`
Let's make some observations:
A [`mapDispatchToProps`](https://react-redux.js.org/api/connect#mapdispatchtoprops-object-dispatch-ownprops-object) function generates props which are used to dispatch Redux actions. This function generally returns props which the component will use as callbacks in response to user actions.
```ts
function mapDispatchToProps(dispatch: any): Partial<ComponentProps> {
return {
// the dispatched message COULD be generated by an
// action creator instead (see later steps)
addTodo: (label: string) => dispatch({ type: 'addTodo', label })
}
}
```
1. The outer function has the same function signature as the previous one
2. It returns a function that has `dispatch` and `getState` as parameters
3. The inner function is `async` enabled, and can await on "side effects" like asynchronous service calls
4. This inner function has the ability to dispatch additional actions because it has been passed the `dispatch()` function from the store
5. This inner function also has access to the state tree via `getState()`

View File

@@ -1,8 +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' })
clear: () => ({ type: 'clear' }),
setFilter: (filter: string) => ({ type: 'setFilter', filter }),
edit: (id: string, label: string) => ({ type: 'edit', id, label })
};
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.update(id, getState().todos[id]);
};
},
clear: () => {
return async (dispatch: any, getState: () => Store) => {
dispatch(actions.clear());
await service.updateAll(getState().todos);
};
},
edit: (id: string, label: string) => {
return async (dispatch: any, getState: () => Store) => {
dispatch(actions.complete(id));
await service.update(id, getState().todos[id]);
};
}
};

View File

@@ -1,26 +1,17 @@
import React from 'react';
import { Stack, Customizer, mergeStyles, getTheme } from 'office-ui-fabric-react';
import { Stack } 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 const TodoApp = () => {
return (
<Customizer {...FluentCustomizations}>
<Stack horizontalAlign="center">
<Stack style={{ width: 400 }} gap={25} className={className}>
<TodoHeader />
<TodoList />
<TodoFooter />
</Stack>
<Stack horizontalAlign="center">
<Stack style={{ width: 400 }} gap={25}>
<TodoHeader />
<TodoList />
<TodoFooter />
</Stack>
</Customizer>
</Stack>
);
};

View File

@@ -1,41 +1,20 @@
import React from 'react';
import { Store } from '../store';
import { Stack, Text, DefaultButton } from 'office-ui-fabric-react';
import { connect } from 'react-redux';
import { actions } from '../actions';
import { DefaultButton, Stack, Text } from 'office-ui-fabric-react';
import { actionsWithService } from '../actions';
import { useMappedState, useDispatch } from 'redux-react-hook';
interface TodoFooterProps {
clear: () => void;
todos: Store['todos'];
}
export const TodoFooter = () => {
const { todos } = useMappedState(state => state);
const dispatch = useDispatch();
const TodoFooter = (props: TodoFooterProps) => {
const { todos } = props;
const itemCount = todos ? Object.keys(todos).filter(id => !props.todos[id].completed).length : 0;
const itemCount = Object.keys(todos).filter(id => !todos[id].completed).length;
return (
<Stack horizontal horizontalAlign="space-between">
<Text>
{itemCount} item{itemCount === 1 ? '' : 's'} left
</Text>
<DefaultButton onClick={() => props.clear()}>Clear Completed</DefaultButton>
<DefaultButton onClick={() => dispatch(actionsWithService.clear())}>Clear Completed</DefaultButton>
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {
clear: () => dispatch(actions.clear())
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoFooter);
export { component as TodoFooter };

View File

@@ -1,19 +1,15 @@
import React from 'react';
import { Text, Stack, 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;
}
import { Stack, Text, Pivot, PivotItem, TextField, PrimaryButton } from 'office-ui-fabric-react';
import { FilterTypes } from '../store';
import { actionsWithService, actions } from '../actions';
import { StoreContext } from 'redux-react-hook';
interface TodoHeaderState {
labelInput: string;
}
class TodoHeader extends React.Component<TodoHeaderProps, TodoHeaderState> {
constructor(props: TodoHeaderProps) {
export class TodoHeader extends React.Component<{}, TodoHeaderState> {
constructor(props: {}) {
super(props);
this.state = { labelInput: undefined };
}
@@ -22,42 +18,48 @@ class TodoHeader extends React.Component<TodoHeaderProps, TodoHeaderState> {
return (
<Stack gap={10}>
<Stack horizontal horizontalAlign="center">
<Text variant="xxLarge">todos - step2-07 demo</Text>
<Text variant="xxLarge">todos</Text>
</Stack>
<Stack horizontal gap={10}>
<Stack.Item grow>
<TextField placeholder="What needs to be done?" value={this.state.labelInput} onChange={this.onChange} />
<TextField
placeholder="What needs to be done?"
value={this.state.labelInput}
onChange={this.onChange}
styles={props => ({
...(props.focused && {
field: {
backgroundColor: '#c7e0f4'
}
})
})}
/>
</Stack.Item>
<PrimaryButton onClick={this.onAdd}>Add</PrimaryButton>
</Stack>
<Pivot onLinkClick={this.onFilter}>
<PivotItem headerText="all" />
<PivotItem headerText="active" />
<PivotItem headerText="completed" />
</Pivot>
</Stack>
);
}
private onAdd = () => {
this.props.addTodo(this.state.labelInput);
this.context.dispatch(actionsWithService.addTodo(this.state.labelInput));
this.setState({ labelInput: undefined });
};
private onChange = (evt: React.FormEvent<HTMLInputElement>, newValue: string) => {
this.setState({ labelInput: newValue });
};
}
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {
addTodo: (label: string) => dispatch(actions.addTodo(label))
private onFilter = (item: PivotItem) => {
this.context.dispatch(actions.setFilter(item.props.headerText as FilterTypes));
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoHeader);
export { component as TodoHeader };
TodoHeader.contextType = StoreContext;

View File

@@ -1,16 +1,13 @@
import React from 'react';
import { Stack } from 'office-ui-fabric-react';
import { TodoListItem } from './TodoListItem';
import { Store } from '../store';
import { connect } from 'react-redux';
import { useMappedState } from 'redux-react-hook';
interface TodoListProps {
todos: Store['todos'];
}
const TodoList = (props: TodoListProps) => {
const { todos } = props;
const filteredTodos = Object.keys(todos);
export const TodoList = () => {
const { filter, todos } = useMappedState(state => state);
const filteredTodos = Object.keys(todos).filter(id => {
return filter === 'all' || (filter === 'completed' && todos[id].completed) || (filter === 'active' && !todos[id].completed);
});
return (
<Stack gap={10}>
@@ -20,18 +17,3 @@ const TodoList = (props: TodoListProps) => {
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList);
export { component as TodoList };

View File

@@ -1,48 +1,78 @@
import React from 'react';
import { Stack, Checkbox, IconButton } from 'office-ui-fabric-react';
import { Store } from '../store';
import { connect } from 'react-redux';
import { actions } from '../actions';
import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react';
import { actionsWithService } from '../actions';
import { StoreContext } from 'redux-react-hook';
interface TodoListItemProps {
id: string;
todos: Store['todos'];
remove: (id: string) => void;
complete: (id: string) => void;
}
class TodoListItem extends React.Component<TodoListItemProps, {}> {
interface TodoListItemState {
editing: boolean;
editLabel: string;
}
export class TodoListItem extends React.Component<TodoListItemProps, TodoListItemState> {
constructor(props: TodoListItemProps) {
super(props);
this.state = { editing: false, editLabel: undefined };
}
render() {
const { todos, id, complete, remove } = this.props;
const { id } = this.props;
const { todos } = this.context.getState();
const dispatch = this.context.dispatch;
const item = todos[id];
return (
<Stack horizontal verticalAlign="center" horizontalAlign="space-between">
<Checkbox label={item.label} checked={item.completed} onChange={() => complete(id)} />
<div>
<IconButton iconProps={{ iconName: 'Cancel' }} onClick={() => remove(id)} />
</div>
{!this.state.editing && (
<>
<Checkbox label={item.label} checked={item.completed} onChange={() => dispatch(actionsWithService.complete(id))} />
<div>
<IconButton iconProps={{ iconName: 'Edit' }} onClick={this.onEdit} />
<IconButton iconProps={{ iconName: 'Cancel' }} onClick={() => dispatch(actionsWithService.remove(id))} />
</div>
</>
)}
{this.state.editing && (
<Stack.Item grow>
<Stack horizontal gap={10}>
<Stack.Item grow>
<TextField value={this.state.editLabel} onChange={this.onChange} />
</Stack.Item>
<DefaultButton onClick={this.onDoneEdit}>Save</DefaultButton>
</Stack>
</Stack.Item>
)}
</Stack>
);
}
}
function mapStateToProps({ todos }: Store) {
return {
todos
private onEdit = () => {
const { id } = this.props;
const { todos } = this.context.getState();
const { label } = todos[id];
this.setState({
editing: true,
editLabel: this.state.editLabel || label
});
};
private onDoneEdit = () => {
this.context.dispatch(actionsWithService.edit(this.props.id, this.state.editLabel));
this.setState({
editing: false,
editLabel: undefined
});
};
private onChange = (evt: React.FormEvent<HTMLInputElement>, newValue: string) => {
this.setState({ editLabel: newValue });
};
}
function mapDispatchToProps(dispatch: any) {
return {
remove: (id: string) => dispatch(actions.remove(id)),
complete: (id: string) => dispatch(actions.complete(id))
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoListItem);
export { component as TodoListItem };
TodoListItem.contextType = StoreContext;

View File

@@ -1,23 +1,30 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { reducer } from './reducers';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { TodoApp } from './components/TodoApp';
import { actions } from './actions';
import { initializeIcons } from '@uifabric/icons';
import { composeWithDevTools } from 'redux-devtools-extension';
import { StoreContext } from 'redux-react-hook';
import thunk from 'redux-thunk';
import { FilterTypes } from './store';
const store = createStore(reducer, {}, composeWithDevTools());
(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
};
store.dispatch(actions.addTodo('hello'));
store.dispatch(actions.addTodo('world'));
const store = createStore(reducer, preloadStore, composeWithDevTools(applyMiddleware(thunk)));
initializeIcons();
initializeIcons();
ReactDOM.render(
<Provider store={store}>
<TodoApp />
</Provider>,
document.getElementById('app')
);
ReactDOM.render(
<StoreContext.Provider value={store}>
<TodoApp />
</StoreContext.Provider>,
document.getElementById('app')
);
})();

View File

@@ -1,27 +1,43 @@
import { Store } from '../store';
import { addTodo, remove, complete, clear } from './pureFunctions';
import { combineReducers } from 'redux';
import { createReducer } from 'redux-starter-kit';
function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] {
switch (action.type) {
case 'addTodo':
return addTodo(state, action.id, action.label);
export const todosReducer = createReducer<Store['todos']>(
{},
{
addTodo(state, action) {
state[action.id] = { label: action.label, completed: false };
},
case 'remove':
return remove(state, action.id);
remove(state, action) {
delete state[action.id];
},
case 'clear':
return clear(state);
clear(state, action) {
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete state[key];
}
});
},
case 'complete':
return complete(state, action.id);
complete(state, action) {
state[action.id].completed = !state[action.id].completed;
},
edit(state, action) {
state[action.id].label = action.label;
}
}
);
return state;
}
export const filterReducer = createReducer<Store['filter']>('all', {
setFilter(state, action) {
return action.filter;
}
});
export function reducer(state: Store, action: any): Store {
return {
todos: todoReducer(state.todos, action),
filter: 'all'
};
}
export const reducer = combineReducers({
todos: todosReducer,
filter: filterReducer
});

View File

@@ -1,29 +0,0 @@
import { addTodo, complete } from './pureFunctions';
import { Store } from '../store';
describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
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 = <Store['todos']>{};
let newState = addTodo(state, '0', 'item1');
const key = Object.keys(newState)[0];
newState = complete(newState, key);
expect(newState[key].completed).toBeTruthy();
});
});

View File

@@ -1,35 +0,0 @@
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 remove(state: Store['todos'], id: string) {
const newTodos = { ...state };
delete newTodos[id];
return newTodos;
}
export function complete(state: Store['todos'], id: string) {
// Clone the todo, overriding
const newTodo = { ...state[id], completed: !state[id].completed };
return { ...state, [id]: newTodo };
}
export function clear(state: Store['todos']) {
const newTodos = { ...state };
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete newTodos[key];
}
});
return newTodos;
}
export function setFilter(state: Store['filter'], filter: FilterTypes) {
return filter;
}

View File

@@ -1,23 +0,0 @@
# Step 2.7: Connect Redux store to view (Exercise)
[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/)
If you still have `npm test` running from the last step, stop it using `ctrl+C`. Start the app by running `npm start` from the root of the `frontend-bootcamp` folder. Click the "exercise" link under day 2 step 7 to see results.
At the beginning of this exercise, the "Add" and "Clear Completed" buttons do not work. We'll be fixing that in this step!
1. Open `exercise/src/index.tsx` and wrap `<TodoApp>` with `<Provider>` as instructed in the comment
2. Open `exercise/src/components/TodoFooter.tsx` and erase the "nullable" type modifier (i.e. the ?) in the interface definition of `TodoFooterProps`
3. Remove the `export` from `export const TodoFooter = (props: TodoFooterProps) => {`
4. Uncomment the bottom bits of code and fill in the implementation for `mapStateToProps()` and `mapDispatchToProps()` - feel free to use `TodoListItem.tsx` as a guide
5. Repeat steps 2, 3, and 4 for the `TodoHeader.tsx` file
## Bonus exercise
For further reading, go here to learn more about the `mergeProps` and `options` parameters to `connect()`:
https://react-redux.js.org/api/connect

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="../../assets/step.css" />
</head>
<body class="ms-Fabric">
<div id="markdownReadme" class="exercise" data-src="./README.md"></div>
<div id="app"></div>
<script src="../../assets/scripts.js"></script>
</body>
</html>

View File

@@ -1,8 +0,0 @@
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' })
};

View File

@@ -1,25 +0,0 @@
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 { FluentCustomizations } from '@uifabric/fluent-theme';
const className = mergeStyles({
padding: 25,
...getTheme().effects.elevation4
});
export const TodoApp = () => {
return (
<Customizer {...FluentCustomizations}>
<Stack horizontalAlign="center">
<Stack style={{ width: 400 }} gap={25} className={className}>
<TodoHeader />
<TodoList />
<TodoFooter />
</Stack>
</Stack>
</Customizer>
);
};

View File

@@ -1,44 +0,0 @@
import React from 'react';
import { Store } from '../store';
import { DefaultButton, Text, Stack } from 'office-ui-fabric-react';
import { connect } from 'react-redux';
import { actions } from '../actions';
// TODO: after connecting to view, erase the ?'s
interface TodoFooterProps {
clear?: () => void;
todos?: Store['todos'];
}
export const TodoFooter = (props: TodoFooterProps) => {
const { todos } = props;
const itemCount = todos ? Object.keys(todos).filter(id => !props.todos[id].completed).length : 0;
return (
<Stack horizontal horizontalAlign="space-between">
<Text>
{itemCount} item{itemCount === 1 ? '' : 's'} left
</Text>
<DefaultButton onClick={() => props.clear()}>Clear Completed</DefaultButton>
</Stack>
);
};
/*
TODO: uncomment this and fill out the below code
function mapStateToProps(state: Store) {
// TODO: FILL THIS OUT
}
function mapDispatchToProps(dispatch: any) {
// TODO: FILL THIS OUT
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoFooter);
export { component as TodoFooter };
*/

View File

@@ -1,68 +0,0 @@
import React from 'react';
import { TextField, PrimaryButton, Text, Stack } from 'office-ui-fabric-react';
import { Store } from '../store';
import { connect } from 'react-redux';
import { actions } from '../actions';
// TODO: after connecting to view, erase the ?'s
interface TodoHeaderProps {
addTodo?: (label: string) => void;
}
interface TodoHeaderState {
labelInput: string;
}
export class TodoHeader extends React.Component<TodoHeaderProps, TodoHeaderState> {
constructor(props: TodoHeaderProps) {
super(props);
this.state = { labelInput: undefined };
}
render() {
return (
<Stack gap={10}>
<Stack horizontal horizontalAlign="center">
<Text variant="xxLarge">todos - step2-07 exercise</Text>
</Stack>
<Stack horizontal gap={10}>
<Stack.Item grow>
<TextField placeholder="What needs to be done?" value={this.state.labelInput} onChange={this.onChange} />
</Stack.Item>
<PrimaryButton onClick={this.onAdd}>Add</PrimaryButton>
</Stack>
</Stack>
);
}
private onAdd = () => {
this.props.addTodo(this.state.labelInput);
this.setState({ labelInput: undefined });
};
private onChange = (evt: React.FormEvent<HTMLInputElement>, newValue: string) => {
this.setState({ labelInput: newValue });
};
}
/*
TODO: uncomment the following and fill out the TODO's
function mapStateToProps(state: Store) {
// TODO: fill this out
}
function mapDispatchToProps(dispatch: any) {
// TODO: fill this out
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoHeader);
export { component as TodoHeader };
*/

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { Stack } from 'office-ui-fabric-react';
import { TodoListItem } from './TodoListItem';
import { Store } from '../store';
import { connect } from 'react-redux';
interface TodoListProps {
todos: Store['todos'];
}
const TodoList = (props: TodoListProps) => {
const { todos } = props;
const filteredTodos = Object.keys(todos);
return (
<Stack gap={10}>
{filteredTodos.map(id => (
<TodoListItem key={id} id={id} />
))}
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList);
export { component as TodoList };

View File

@@ -1,48 +0,0 @@
import React from 'react';
import { Stack, Checkbox, IconButton } from 'office-ui-fabric-react';
import { Store } from '../store';
import { connect } from 'react-redux';
import { actions } from '../actions';
interface TodoListItemProps {
id: string;
todos: Store['todos'];
remove: (id: string) => void;
complete: (id: string) => void;
}
class TodoListItem extends React.Component<TodoListItemProps, {}> {
render() {
const { todos, id, complete, remove } = this.props;
const item = todos[id];
return (
<Stack horizontal verticalAlign="center" horizontalAlign="space-between">
<Checkbox label={item.label} checked={item.completed} onChange={() => complete(id)} />
<div>
<IconButton iconProps={{ iconName: 'Cancel' }} onClick={() => remove(id)} />
</div>
</Stack>
);
}
}
function mapStateToProps({ todos }: Store) {
return {
todos
};
}
function mapDispatchToProps(dispatch: any) {
return {
remove: (id: string) => dispatch(actions.remove(id)),
complete: (id: string) => dispatch(actions.complete(id))
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoListItem);
export { component as TodoListItem };

View File

@@ -1,24 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { reducer } from './reducers';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { TodoApp } from './components/TodoApp';
import { actions } from './actions';
import { initializeIcons } from '@uifabric/icons';
import { composeWithDevTools } from 'redux-devtools-extension';
const store = createStore(reducer, {}, composeWithDevTools());
store.dispatch(actions.addTodo('hello'));
store.dispatch(actions.addTodo('world'));
initializeIcons();
// TODO: see how we added Provider is the root element
ReactDOM.render(
<Provider store={store}>
<TodoApp />
</Provider>,
document.getElementById('app')
);

View File

@@ -1,27 +0,0 @@
import { Store } from '../store';
import { addTodo, remove, complete, clear } from './pureFunctions';
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;
}
export function reducer(state: Store, action: any): Store {
return {
todos: todoReducer(state.todos, action),
filter: 'all'
};
}

View File

@@ -1,29 +0,0 @@
import { addTodo, complete } from './pureFunctions';
import { Store } from '../store';
describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
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 = <Store['todos']>{};
let newState = addTodo(state, '0', 'item1');
const key = Object.keys(newState)[0];
newState = complete(newState, key);
expect(newState[key].completed).toBeTruthy();
});
});

View File

@@ -1,35 +0,0 @@
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 remove(state: Store['todos'], id: string) {
const newTodos = { ...state };
delete newTodos[id];
return newTodos;
}
export function complete(state: Store['todos'], id: string) {
// Clone the todo, overriding
const newTodo = { ...state[id], completed: !state[id].completed };
return { ...state, [id]: newTodo };
}
export function clear(state: Store['todos']) {
const newTodos = { ...state };
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete newTodos[key];
}
});
return newTodos;
}
export function setFilter(state: Store['filter'], filter: FilterTypes) {
return filter;
}

View File

@@ -1,14 +0,0 @@
export type FilterTypes = 'all' | 'active' | 'completed';
export interface TodoItem {
label: string;
completed: boolean;
}
export interface Store {
todos: {
[id: string]: TodoItem;
};
filter: FilterTypes;
}

View File

@@ -1,147 +0,0 @@
# Step 2.8: Reduce Boilerplate (Demo)
[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/)
At this point, you might asking why am I adding so much boilerplate code?
<details>
<summary>It's okay. Don't be cry.</summary>
<img src="https://media.giphy.com/media/eveLVPcHcbl0A/giphy.gif" />
</details>
A lot of code seems to be repeated with Redux. Redux is very much function-based and has a lot of opportunities for some refactoring to make it less boilerplate-heavy.
I argue that part of the boilerplate is just making things explicit that would otherwise be implicit. This is GOOD in a large application so that there is no magic.
However, I argue that there are two major areas for improvement:
1. Writing against immutable data structures is hard
2. The switch statements are cumbersome and error-prone (e.g. with default case missing)
## `redux-starter-kit`: A simple batteries-included toolset to make using Redux easier
Introducing [`redux-starter-kit`](https://redux-starter-kit.js.org/), an official helper library from Redux team, makes this much better. We'll start with `createReducer()`.
### `createReducer()`: takes away the switch statement
[`createReducer()`](https://redux-starter-kit.js.org/api/createreducer) simplifies things a lot! The best way illustrate what it does is with some code. Previously, we'd write our reducer like this:
```ts
function todoReducer(state, action) {
switch (action.type) {
case 'addTodo':
return addTodo(...)
case 'remove':
return remove(...)
case 'clear':
return clear(...)
case 'complete':
return complete(...)
}
return state;
}
```
We can rewrite this with `redux-starter-kit` as follows:
```ts
import { createReducer } from 'redux-starter-kit';
const todoReducer = createReducer({}, {
addTodo: (state, action) => ...,
remove: (state, action) => ...,
clear: (state, action) => ...,
complete: (state, action) => ...
})
```
Several important features of `createReducer()`:
1. Provides a more concise way of writing reducers, using an object with keys that match the possible values of `action.type`
2. Handles "no match" case and returns the previous state (rather than a blank state like we had done previously)
3. it incorporates a library called [`immer`](https://github.com/mweststrate/immer#reducer-example) that allows us to write code that mutates a draft object and ultimately copies over the old snapshot with the new. Instead of writing immutable data manipulation:
```ts
// Taken from: https://redux.js.org/recipes/structuring-reducers/immutable-update-patterns#inserting-and-removing-items-in-arrays
function insertItem(array, action) {
return [...array.slice(0, action.index), action.item, ...array.slice(action.index)];
}
function removeItem(array, action) {
return [...array.slice(0, action.index), ...array.slice(action.index + 1)];
}
```
We can write code with mutable arrays (without spread syntax):
```ts
function insertItem(array, action) {
// splice is a standard JS Array function
array.splice(action.index, 0, action.item);
}
function removeItem(array, action) {
array.splice(action.index, 1);
}
```
In cases where you need to replace the entire state (like `setFilter`), simply return a new value without modifying the state like so:
```ts
function setFilter(state, action) {
return action.filter;
}
```
### `combineReducers()` - combining reducers
Using [`combineReducers()`](https://redux.js.org/recipes/structuring-reducers/using-combinereducers), we can further reduce the amount of boilerplate code. As the application store evolves and is responsible for increasing amounts of state, it becomes advantageous to decompose the reducer into smaller functions. `combineReducers()` provides an API that lets authors build more, smaller reducers, each with a single responsibility.
Our todo app's Redux store so far has this shape, roughly:
```js
const state = {
todos: {
id0: {
label: 'hello',
completed: false
},
id1: {
label: 'world',
completed: true
}
},
filter: 'all'
};
```
Currently, the store captures two separate but related pieces of data: the todo items and the selected filter. The reducers should follow the shape of the store. Think of reducers as parts of the store which are responsible to update a single part of the store based on the action passed in. As complexity of state grows, we split these reducers:
```ts
// from last step, using createReducer
const todoReducer = createReducer(
{},
{
// reduce on the todos part of the state tree
}
);
const filterReducer = createReducer('all', {
// reduce on the filter flag
});
// Then use the redux-provided combineReducers() to combine them
export const reducer = combineReducers({
todos: todoReducer,
filter: filterReducer
});
```
`combineReducers` handles the grunt work of sending actions to the appropriate reducer. Therefore, when an action arrives, each reducer is given the opportunity to modify its own section of the state tree based on the incoming action.

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="../../assets/step.css" />
</head>
<body class="ms-Fabric">
<div id="markdownReadme" data-src="./README.md"></div>
<div id="app"></div>
<script src="../../assets/scripts.js"></script>
</body>
</html>

View File

@@ -1,9 +0,0 @@
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 })
};

View File

@@ -1,26 +0,0 @@
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 const TodoApp = () => {
return (
<Customizer {...FluentCustomizations}>
<Stack horizontalAlign="center">
<Stack style={{ width: 400 }} gap={25} className={className}>
<TodoHeader />
<TodoList />
<TodoFooter />
</Stack>
</Stack>
</Customizer>
);
};

View File

@@ -1,41 +0,0 @@
import React from 'react';
import { Store } from '../store';
import { DefaultButton, Text, Stack } from 'office-ui-fabric-react';
import { connect } from 'react-redux';
import { actions } from '../actions';
interface TodoFooterProps {
clear: () => void;
todos: Store['todos'];
}
const TodoFooter = (props: TodoFooterProps) => {
const { todos } = props;
const itemCount = todos ? Object.keys(todos).filter(id => !props.todos[id].completed).length : 0;
return (
<Stack horizontal horizontalAlign="space-between">
<Text>
{itemCount} item{itemCount === 1 ? '' : 's'} left
</Text>
<DefaultButton onClick={() => props.clear()}>Clear Completed</DefaultButton>
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {
clear: () => dispatch(actions.clear())
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoFooter);
export { component as TodoFooter };

View File

@@ -1,76 +0,0 @@
import React from 'react';
import { Pivot, PivotItem, TextField, PrimaryButton, Text, Stack } from 'office-ui-fabric-react';
import { FilterTypes, Store } from '../store';
import { 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<TodoHeaderProps, TodoHeaderState> {
constructor(props: TodoHeaderProps) {
super(props);
this.state = { labelInput: undefined };
}
render() {
return (
<Stack gap={10}>
<Stack horizontal horizontalAlign="center">
<Text variant="xxLarge">todos - step2-08 demo</Text>
</Stack>
<Stack horizontal gap={10}>
<Stack.Item grow>
<TextField placeholder="What needs to be done?" value={this.state.labelInput} onChange={this.onChange} />
</Stack.Item>
<PrimaryButton onClick={this.onAdd}>Add</PrimaryButton>
</Stack>
<Pivot onLinkClick={this.onFilter}>
<PivotItem headerText="all" />
<PivotItem headerText="active" />
<PivotItem headerText="completed" />
</Pivot>
</Stack>
);
}
private onAdd = () => {
this.props.addTodo(this.state.labelInput);
this.setState({ labelInput: undefined });
};
private onChange = (evt: React.FormEvent<HTMLInputElement>, 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(actions.addTodo(label)),
setFilter: (filter: FilterTypes) => dispatch(actions.setFilter(filter))
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoHeader);
export { component as TodoHeader };

View File

@@ -1,40 +0,0 @@
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 (
<Stack gap={10}>
{filteredTodos.map(id => (
<TodoListItem key={id} id={id} />
))}
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList);
export { component as TodoList };

View File

@@ -1,48 +0,0 @@
import React from 'react';
import { Stack, Checkbox, IconButton } from 'office-ui-fabric-react';
import { Store } from '../store';
import { connect } from 'react-redux';
import { actions } from '../actions';
interface TodoListItemProps {
id: string;
todos: Store['todos'];
remove: (id: string) => void;
complete: (id: string) => void;
}
class TodoListItem extends React.Component<TodoListItemProps, {}> {
render() {
const { todos, id, complete, remove } = this.props;
const item = todos[id];
return (
<Stack horizontal verticalAlign="center" horizontalAlign="space-between">
<Checkbox label={item.label} checked={item.completed} onChange={() => complete(id)} />
<div>
<IconButton iconProps={{ iconName: 'Cancel' }} onClick={() => remove(id)} />
</div>
</Stack>
);
}
}
function mapStateToProps({ todos }: Store) {
return {
todos
};
}
function mapDispatchToProps(dispatch: any) {
return {
remove: (id: string) => dispatch(actions.remove(id)),
complete: (id: string) => dispatch(actions.complete(id))
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoListItem);
export { component as TodoListItem };

View File

@@ -1,23 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { reducer } from './reducers';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { TodoApp } from './components/TodoApp';
import { actions } from './actions';
import { initializeIcons } from '@uifabric/icons';
import { composeWithDevTools } from 'redux-devtools-extension';
const store = createStore(reducer, {}, composeWithDevTools());
store.dispatch(actions.addTodo('hello'));
store.dispatch(actions.addTodo('world'));
initializeIcons();
ReactDOM.render(
<Provider store={store}>
<TodoApp />
</Provider>,
document.getElementById('app')
);

View File

@@ -1,23 +0,0 @@
import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux';
import { createReducer } from 'redux-starter-kit';
const todoReducer = createReducer<Store['todos']>(
{},
{
addTodo,
remove,
clear,
complete
}
);
const filterReducer = createReducer<Store['filter']>('all', {
setFilter
});
export const reducer = combineReducers({
todos: todoReducer,
filter: filterReducer
});

View File

@@ -1,23 +0,0 @@
import { addTodo, complete } from './pureFunctions';
import { Store } from '../store';
describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(state);
expect(keys.length).toBe(1);
expect(state['0'].label).toBe('item1');
expect(state['0'].completed).toBeFalsy();
});
it('can complete an item', () => {
const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
complete(state, { id: '0' });
expect(state['0'].completed).toBeTruthy();
});
});

View File

@@ -1,25 +0,0 @@
import { Store } from '../store';
export function addTodo(state: Store['todos'], action: any) {
state[action.id] = { label: action.label, completed: false };
}
export function remove(state: Store['todos'], action: any) {
delete state[action.id];
}
export function complete(state: Store['todos'], action: any) {
state[action.id].completed = !state[action.id].completed;
}
export function clear(state: Store['todos']) {
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete state[key];
}
});
}
export function setFilter(state: Store['filter'], action: any) {
return action.filter;
}

View File

@@ -1,14 +0,0 @@
export type FilterTypes = 'all' | 'active' | 'completed';
export interface TodoItem {
label: string;
completed: boolean;
}
export interface Store {
todos: {
[id: string]: TodoItem;
};
filter: FilterTypes;
}

View File

@@ -1,23 +0,0 @@
# Step 2.8: Reduce Boilerplate (Exercise)
[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/)
If you don't already have the app running, start it by running `npm start` from the root of the `frontend-bootcamp` folder. Click the "exercise" link under day 2 step 8 to see results.
> Hint! This section is tricky, so the whole solution is inside `demo` as usual. Feel free to copy & paste if you get stuck!!
1. Open up `exercise/src/reducers/index.ts`
2. Rewrite the reducer functions `todoReducers` and `filterReducers` with the help of [`createReducer()`](https://redux-starter-kit.js.org/api/createreducer)
3. Rewrite the `reducer()` function with [`combineReducers()`](https://redux.js.org/recipes/structuring-reducers/using-combinereducers)
4. Open up `exercise/src/reducers/pureFunctions.ts`
5. Rewrite all the reducers related to the todos by following the instructions in the code
## Further reading
- [`immer`](https://github.com/mweststrate/immer) - Improves ergonomics of working with immutables by introducing the concept of mutating a draft
- [`redux-starter-kit`](https://github.com/reduxjs/redux-starter-kit) - Help address common concerns of Redux in boilerplate and complexity

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="../../assets/step.css" />
</head>
<body class="ms-Fabric">
<div id="markdownReadme" class="exercise" data-src="./README.md"></div>
<div id="app"></div>
<script src="../../assets/scripts.js"></script>
</body>
</html>

View File

@@ -1,9 +0,0 @@
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 })
};

View File

@@ -1,26 +0,0 @@
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 const TodoApp = () => {
return (
<Customizer {...FluentCustomizations}>
<Stack horizontalAlign="center">
<Stack style={{ width: 400 }} gap={25} className={className}>
<TodoHeader />
<TodoList />
<TodoFooter />
</Stack>
</Stack>
</Customizer>
);
};

View File

@@ -1,40 +0,0 @@
import React from 'react';
import { Store } from '../store';
import { DefaultButton, Text, Stack } from 'office-ui-fabric-react';
import { connect } from 'react-redux';
import { actions } 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 (
<Stack horizontal horizontalAlign="space-between">
<Text>
{itemCount} item{itemCount === 1 ? '' : 's'} left
</Text>
<DefaultButton onClick={() => props.clear()}>Clear Completed</DefaultButton>
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {
clear: () => dispatch(actions.clear())
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoFooter);
export { component as TodoFooter };

View File

@@ -1,76 +0,0 @@
import React from 'react';
import { Pivot, PivotItem, TextField, PrimaryButton, Stack, Text } from 'office-ui-fabric-react';
import { FilterTypes, Store } from '../store';
import { 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<TodoHeaderProps, TodoHeaderState> {
constructor(props: TodoHeaderProps) {
super(props);
this.state = { labelInput: undefined };
}
render() {
return (
<Stack gap={10}>
<Stack horizontal horizontalAlign="center">
<Text variant="xxLarge">todos - step2-08 exercise</Text>
</Stack>
<Stack horizontal gap={10}>
<Stack.Item grow>
<TextField placeholder="What needs to be done?" value={this.state.labelInput} onChange={this.onChange} />
</Stack.Item>
<PrimaryButton onClick={this.onAdd}>Add</PrimaryButton>
</Stack>
<Pivot onLinkClick={this.onFilter}>
<PivotItem headerText="all" />
<PivotItem headerText="active" />
<PivotItem headerText="completed" />
</Pivot>
</Stack>
);
}
private onAdd = () => {
this.props.addTodo(this.state.labelInput);
this.setState({ labelInput: undefined });
};
private onChange = (evt: React.FormEvent<HTMLInputElement>, 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(actions.addTodo(label)),
setFilter: (filter: FilterTypes) => dispatch(actions.setFilter(filter))
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoHeader);
export { component as TodoHeader };

View File

@@ -1,40 +0,0 @@
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 (
<Stack gap={10}>
{filteredTodos.map(id => (
<TodoListItem key={id} id={id} />
))}
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList);
export { component as TodoList };

View File

@@ -1,48 +0,0 @@
import React from 'react';
import { Stack, Checkbox, IconButton } from 'office-ui-fabric-react';
import { Store } from '../store';
import { connect } from 'react-redux';
import { actions } from '../actions';
interface TodoListItemProps {
id: string;
todos: Store['todos'];
remove: (id: string) => void;
complete: (id: string) => void;
}
class TodoListItem extends React.Component<TodoListItemProps, {}> {
render() {
const { todos, id, complete, remove } = this.props;
const item = todos[id];
return (
<Stack horizontal verticalAlign="center" horizontalAlign="space-between">
<Checkbox label={item.label} checked={item.completed} onChange={() => complete(id)} />
<div>
<IconButton iconProps={{ iconName: 'Cancel' }} onClick={() => remove(id)} />
</div>
</Stack>
);
}
}
function mapStateToProps({ todos }: Store) {
return {
todos
};
}
function mapDispatchToProps(dispatch: any) {
return {
remove: (id: string) => dispatch(actions.remove(id)),
complete: (id: string) => dispatch(actions.complete(id))
};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoListItem);
export { component as TodoListItem };

View File

@@ -1,23 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { reducer } from './reducers';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { TodoApp } from './components/TodoApp';
import { actions } from './actions';
import { initializeIcons } from '@uifabric/icons';
import { composeWithDevTools } from 'redux-devtools-extension';
const store = createStore(reducer, {}, composeWithDevTools());
store.dispatch(actions.addTodo('hello'));
store.dispatch(actions.addTodo('world'));
initializeIcons();
ReactDOM.render(
<Provider store={store}>
<TodoApp />
</Provider>,
document.getElementById('app')
);

View File

@@ -1,35 +0,0 @@
import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux';
// TODO: rewrite this with createReducer() function
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;
}
// TODO: rewrite this with createReducer() function
function filterReducer(state: Store['filter'] = 'all', action: any): Store['filter'] {
return state;
}
// TODO: rewrite this reducer function with combineReducer() helper
export function reducer(state: Store, action: any): Store {
return {
todos: todoReducer(state.todos, action),
filter: 'all'
};
}

View File

@@ -1,49 +0,0 @@
import { Store, FilterTypes } from '../store';
// TODO: for all the "todos" functions here, rewrite with mutable state
// 1. !!!IMPORTANT!!! change the signature of every function here to:
// function xyzAction(state: Store['todos'], action: any) { ... }
//
// 2. make sure NOT to return anything, just modify the "state" arg
export function addTodo(state: Store['todos'], id: string, label: string) {
// hint: state[action.id] = ...
return { ...state, [id]: { label, completed: false } };
}
export function remove(state: Store['todos'], id: string) {
// hint: delete state[action.id]
const newTodos = { ...state };
delete newTodos[id];
return newTodos;
}
export function complete(state: Store['todos'], id: string) {
// hint: state[action.id].completed = ...
const newTodos = { ...state };
newTodos[id].completed = !newTodos[id].completed;
return newTodos;
}
export function clear(state: Store['todos']) {
// hint: it's almost like the remove case above
const newTodos = { ...state };
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete newTodos[key];
}
});
return newTodos;
}
// TODO: change the setFilter() to the new immer way
// 1. change the signature of every function here to:
// function xyzAction(state: Store['todos'], action: any) { ... }
// 2. make sure to return action.filter without modifying state in this case
export function setFilter(state: Store['filter'], filter: FilterTypes) {
return filter;
}

View File

@@ -1,14 +0,0 @@
export type FilterTypes = 'all' | 'active' | 'completed';
export interface TodoItem {
label: string;
completed: boolean;
}
export interface Store {
todos: {
[id: string]: TodoItem;
};
filter: FilterTypes;
}

View File

@@ -1,48 +0,0 @@
# Step 2.9: Service calls (Demo)
[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/)
> Note: this step doesn't work with the live site on github.io. Clone the repo to try this step out.
## `redux-thunk`: side effects inside action creators
The [Redux Thunk](https://github.com/reduxjs/redux-thunk) middleware allows writing actions that make service calls.
Remember those simple little action functions? They're called action creators. These little functions can be charged with superpowers to allow asynchronous side effects to happen while creating the messages. Asynchronous side effects include service calls against APIs.
Action creators are a natural place to put service calls. Redux Thunk middleware passes `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.
## Action creator with a thunk
[What's a thunk?](https://daveceddia.com/what-is-a-thunk/) - it is a wrapper function that returns a function. What does it do? Let's find out!
This action creator just returns an object:
```ts
function addTodo(label: string) {
return { type: 'addTodo', id: uuid(), label };
}
```
In order for us to make service calls, we need to supercharge this with the power of `redux-thunk`
```ts
function 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]);
};
}
```
Let's make some observations:
1. The outer function has the same function signature as the previous one
2. It returns a function that has `dispatch` and `getState` as parameters
3. The inner function is `async` enabled, and can await on "side effects" like asynchronous service calls
4. This inner function has the ability to dispatch additional actions because it has been passed the `dispatch()` function from the store
5. This inner function also has access to the state tree via `getState()`

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="../../assets/step.css" />
</head>
<body class="ms-Fabric">
<div id="markdownReadme" data-src="./README.md"></div>
<div id="app"></div>
<script src="../../assets/scripts.js"></script>
</body>
</html>

View File

@@ -1,43 +0,0 @@
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.update(id, getState().todos[id]);
};
},
clear: () => {
return async (dispatch: any, getState: () => Store) => {
dispatch(actions.clear());
await service.updateAll(getState().todos);
};
}
};

View File

@@ -1,36 +0,0 @@
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<any, Store> {
constructor(props) {
super(props);
this.state = {
todos: {},
filter: 'all'
};
}
render() {
const { filter, todos } = this.state;
return (
<Customizer {...FluentCustomizations}>
<Stack horizontalAlign="center">
<Stack style={{ width: 400 }} gap={25} className={className}>
<TodoHeader />
<TodoList />
<TodoFooter />
</Stack>
</Stack>
</Customizer>
);
}
}

View File

@@ -1,40 +0,0 @@
import React from 'react';
import { Store } from '../store';
import { DefaultButton, Text, Stack } 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 (
<Stack horizontal horizontalAlign="space-between">
<Text>
{itemCount} item{itemCount === 1 ? '' : 's'} left
</Text>
<DefaultButton onClick={() => props.clear()}>Clear Completed</DefaultButton>
</Stack>
);
};
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 };

View File

@@ -1,76 +0,0 @@
import React from 'react';
import { Pivot, PivotItem, TextField, PrimaryButton, Text, Stack } 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<TodoHeaderProps, TodoHeaderState> {
constructor(props: TodoHeaderProps) {
super(props);
this.state = { labelInput: undefined };
}
render() {
return (
<Stack gap={10}>
<Stack horizontal horizontalAlign="center">
<Text variant="xxLarge">todos - step2-09 demo</Text>
</Stack>
<Stack horizontal gap={10}>
<Stack.Item grow>
<TextField placeholder="What needs to be done?" value={this.state.labelInput} onChange={this.onChange} />
</Stack.Item>
<PrimaryButton onClick={this.onAdd}>Add</PrimaryButton>
</Stack>
<Pivot onLinkClick={this.onFilter}>
<PivotItem headerText="all" />
<PivotItem headerText="active" />
<PivotItem headerText="completed" />
</Pivot>
</Stack>
);
}
private onAdd = () => {
this.props.addTodo(this.state.labelInput);
this.setState({ labelInput: undefined });
};
private onChange = (evt: React.FormEvent<HTMLInputElement>, 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 };

View File

@@ -1,40 +0,0 @@
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 (
<Stack gap={10}>
{filteredTodos.map(id => (
<TodoListItem key={id} id={id} />
))}
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList);
export { component as TodoList };

View File

@@ -1,48 +0,0 @@
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<TodoListItemProps, {}> {
render() {
const { todos, id, complete, remove } = this.props;
const item = todos[id];
return (
<Stack horizontal verticalAlign="center" horizontalAlign="space-between">
<Checkbox label={item.label} checked={item.completed} onChange={() => complete(id)} />
<div>
<IconButton iconProps={{ iconName: 'Cancel' }} onClick={() => remove(id)} />
</div>
</Stack>
);
}
}
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 };

View File

@@ -1,29 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { reducer } from './reducers';
import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { TodoApp } from './components/TodoApp';
import { initializeIcons } from '@uifabric/icons';
import { FilterTypes } from './store';
import * as service from './service';
import { composeWithDevTools } from 'redux-devtools-extension';
(async () => {
const preloadStore = {
todos: await service.getAll(),
filter: 'all' as FilterTypes
};
const store = createStore(reducer, preloadStore, composeWithDevTools(applyMiddleware(thunk)));
initializeIcons();
ReactDOM.render(
<Provider store={store}>
<TodoApp />
</Provider>,
document.getElementById('app')
);
})();

View File

@@ -1,23 +0,0 @@
import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux';
import { createReducer } from 'redux-starter-kit';
const todoReducer = createReducer<Store['todos']>(
{},
{
addTodo,
remove,
clear,
complete
}
);
const filterReducer = createReducer<Store['filter']>('all', {
setFilter
});
export const reducer = combineReducers({
todos: todoReducer,
filter: filterReducer
});

View File

@@ -1,23 +0,0 @@
import { addTodo, complete } from './pureFunctions';
import { Store } from '../store';
describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(state);
expect(keys.length).toBe(1);
expect(state['0'].label).toBe('item1');
expect(state['0'].completed).toBeFalsy();
});
it('can complete an item', () => {
const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
complete(state, { id: '0' });
expect(state['0'].completed).toBeTruthy();
});
});

View File

@@ -1,25 +0,0 @@
import { Store } from '../store';
export function addTodo(state: Store['todos'], action: any) {
state[action.id] = { label: action.label, completed: false };
}
export function remove(state: Store['todos'], action: any) {
delete state[action.id];
}
export function complete(state: Store['todos'], action: any) {
state[action.id].completed = !state[action.id].completed;
}
export function clear(state: Store['todos']) {
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete state[key];
}
});
}
export function setFilter(state: Store['filter'], action: any) {
return action.filter;
}

View File

@@ -1,14 +0,0 @@
export type FilterTypes = 'all' | 'active' | 'completed';
export interface TodoItem {
label: string;
completed: boolean;
}
export interface Store {
todos: {
[id: string]: TodoItem;
};
filter: FilterTypes;
}

View File

@@ -1,14 +0,0 @@
# Step 2.9: Service Calls (Exercise)
[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/)
> Note: this step doesn't work with the live site on github.io. Clone the repo to try this step out.
If you don't already have the app running, start it by running `npm start` from the root of the `frontend-bootcamp` folder. Click the "exercise" link under day 2 step 9 to see results.
1. Open `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 pre-populate with data from the service

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="../../assets/step.css" />
</head>
<body class="ms-Fabric">
<div id="markdownReadme" class="exercise" data-src="./README.md"></div>
<div id="app"></div>
<script src="../../assets/scripts.js"></script>
</body>
</html>

View File

@@ -1,51 +0,0 @@
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 (because the id was generated by that addTodo() call)
// 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);
}
};

View File

@@ -1,36 +0,0 @@
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<any, Store> {
constructor(props) {
super(props);
this.state = {
todos: {},
filter: 'all'
};
}
render() {
const { filter, todos } = this.state;
return (
<Customizer {...FluentCustomizations}>
<Stack horizontalAlign="center">
<Stack style={{ width: 400 }} gap={25} className={className}>
<TodoHeader />
<TodoList />
<TodoFooter />
</Stack>
</Stack>
</Customizer>
);
}
}

View File

@@ -1,40 +0,0 @@
import React from 'react';
import { Store } from '../store';
import { DefaultButton, Text, Stack } 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 (
<Stack horizontal horizontalAlign="space-between">
<Text>
{itemCount} item{itemCount === 1 ? '' : 's'} left
</Text>
<DefaultButton onClick={() => props.clear()}>Clear Completed</DefaultButton>
</Stack>
);
};
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 };

View File

@@ -1,76 +0,0 @@
import React from 'react';
import { Pivot, PivotItem, TextField, PrimaryButton, Text, Stack } 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<TodoHeaderProps, TodoHeaderState> {
constructor(props: TodoHeaderProps) {
super(props);
this.state = { labelInput: undefined };
}
render() {
return (
<Stack gap={10}>
<Stack horizontal horizontalAlign="center">
<Text variant="xxLarge">todos - step2-09 exercise</Text>
</Stack>
<Stack horizontal gap={10}>
<Stack.Item grow>
<TextField placeholder="What needs to be done?" value={this.state.labelInput} onChange={this.onChange} />
</Stack.Item>
<PrimaryButton onClick={this.onAdd}>Add</PrimaryButton>
</Stack>
<Pivot onLinkClick={this.onFilter}>
<PivotItem headerText="all" />
<PivotItem headerText="active" />
<PivotItem headerText="completed" />
</Pivot>
</Stack>
);
}
private onAdd = () => {
this.props.addTodo(this.state.labelInput);
this.setState({ labelInput: undefined });
};
private onChange = (evt: React.FormEvent<HTMLInputElement>, 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 };

View File

@@ -1,40 +0,0 @@
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 (
<Stack gap={10}>
{filteredTodos.map(id => (
<TodoListItem key={id} id={id} />
))}
</Stack>
);
};
function mapStateToProps(state: Store) {
return { ...state };
}
function mapDispatchToProps(dispatch: any) {
return {};
}
const component = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList);
export { component as TodoList };

View File

@@ -1,48 +0,0 @@
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<TodoListItemProps, {}> {
render() {
const { todos, id, complete, remove } = this.props;
const item = todos[id];
return (
<Stack horizontal verticalAlign="center" horizontalAlign="space-between">
<Checkbox label={item.label} checked={item.completed} onChange={() => complete(id)} />
<div>
<IconButton iconProps={{ iconName: 'Cancel' }} onClick={() => remove(id)} />
</div>
</Stack>
);
}
}
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 };

View File

@@ -1,31 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { reducer } from './reducers';
import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { TodoApp } from './components/TodoApp';
import { initializeIcons } from '@uifabric/icons';
import { FilterTypes } from './store';
import * as service from './service';
import { composeWithDevTools } from 'redux-devtools-extension';
(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 = createStore(reducer, preloadStore, composeWithDevTools(applyMiddleware(thunk)));
initializeIcons();
ReactDOM.render(
<Provider store={store}>
<TodoApp />
</Provider>,
document.getElementById('app')
);
})();

View File

@@ -1,23 +0,0 @@
import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux';
import { createReducer } from 'redux-starter-kit';
const todoReducer = createReducer<Store['todos']>(
{},
{
addTodo,
remove,
clear,
complete
}
);
const filterReducer = createReducer<Store['filter']>('all', {
setFilter
});
export const reducer = combineReducers({
todos: todoReducer,
filter: filterReducer
});

View File

@@ -1,23 +0,0 @@
import { addTodo, complete } from './pureFunctions';
import { Store } from '../store';
describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(state);
expect(keys.length).toBe(1);
expect(state['0'].label).toBe('item1');
expect(state['0'].completed).toBeFalsy();
});
it('can complete an item', () => {
const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
complete(state, { id: '0' });
expect(state['0'].completed).toBeTruthy();
});
});

View File

@@ -1,25 +0,0 @@
import { Store } from '../store';
export function addTodo(state: Store['todos'], action: any) {
state[action.id] = { label: action.label, completed: false };
}
export function remove(state: Store['todos'], action: any) {
delete state[action.id];
}
export function complete(state: Store['todos'], action: any) {
state[action.id].completed = !state[action.id].completed;
}
export function clear(state: Store['todos']) {
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete state[key];
}
});
}
export function setFilter(state: Store['filter'], action: any) {
return action.filter;
}

View File

@@ -1,48 +0,0 @@
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();
}

View File

@@ -1,14 +0,0 @@
export type FilterTypes = 'all' | 'active' | 'completed';
export interface TodoItem {
label: string;
completed: boolean;
}
export interface Store {
todos: {
[id: string]: TodoItem;
};
filter: FilterTypes;
}