mirror of
https://github.com/microsoft/frontend-bootcamp.git
synced 2026-01-26 14:56:42 +08:00
adding step 2.4 and 2.5 exercises
This commit is contained in:
109
step2-04/exercise/README.md
Normal file
109
step2-04/exercise/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Step 2.4 - React Context (Exercise)
|
||||
|
||||
[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/)
|
||||
|
||||
In this step, we describe some problems we encounter when creating a more complex application.
|
||||
|
||||
We will solve these problems with the React Context API. The Context API consists of:
|
||||
|
||||
1. Provider component
|
||||
2. Consuming context from a Class Component
|
||||
3. Consuming context from a Functional Component
|
||||
|
||||
---
|
||||
|
||||
React represents a single component like this:
|
||||
|
||||
```
|
||||
(props) => view;
|
||||
```
|
||||
|
||||
In a real application, these functions are composed. It looks more like this:
|
||||
|
||||

|
||||
|
||||
## Problems in a Complex Application
|
||||
|
||||
1. Data needs to be passed down from component to component via props. Even when some components do not need to know about some data. This is a problem called **props drilling**
|
||||
|
||||
2. There is a lack of coordination of changes that can happen to the data
|
||||
|
||||
Even in our simple application, we saw this problem. For example, `<TodoList>` has this props interface:
|
||||
|
||||
```ts
|
||||
interface TodoListProps {
|
||||
complete: (id: string) => void;
|
||||
remove: (id: string) => void;
|
||||
todos: Store['todos'];
|
||||
filter: FilterTypes;
|
||||
edit: (id: string, label: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
All of these props are not used, except to be passed down to a child Component, `TodoListItem`:
|
||||
|
||||
```js
|
||||
<TodoListItem todos="{todos}" complete="{complete}" remove="{remove}" edit="{edit}" />
|
||||
```
|
||||
|
||||
## Context API
|
||||
|
||||
Let's solve these problems with the React Context API. _context_ is React's way to share data from components to their descendant children components without explicitly passing down through props at every level of the tree. React context is created by calling `createContext()` with some initial data. Use the `<TodoContext.Provider>` component to wrap a part of the component tree that should be handed the _context_.
|
||||
|
||||
```js
|
||||
// To create a completed empty context
|
||||
const TodoContext = React.createContext(undefined);
|
||||
|
||||
class TodoApp extends React.Component {
|
||||
render() {
|
||||
|
||||
// Pass in some state and function to the provider's value prop
|
||||
return (
|
||||
<TodoContext.Provider
|
||||
value={{
|
||||
...this.state,
|
||||
addTodo={this._addTodo},
|
||||
setFilter={this._setFilter},
|
||||
/* same goes for remove, complete, and clear */
|
||||
}}>
|
||||
<div>
|
||||
<TodoHeader />
|
||||
<TodoList />
|
||||
<TodoFooter />
|
||||
</div>
|
||||
</TodoContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Consume _context_ from a Class Component
|
||||
|
||||
Inside the children components, like the `<TodoHeader>` component, the value can be access from the component's `context` prop like this:
|
||||
|
||||
```js
|
||||
class TodoHeader extends React.Component {
|
||||
render() {
|
||||
// Step 1: use the context prop
|
||||
return <div>Filter is {this.context.filter}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: be sure to set the contextType property of the component class
|
||||
TodoHeader.contextType = TodoContext;
|
||||
```
|
||||
|
||||
### Consume _context_ from a Functional Component
|
||||
|
||||
If you're using the functional component syntax, you can access the context with the `useContext()` function. `useContext()` requires a recent release of React (16.8):
|
||||
|
||||
```js
|
||||
const TodoFooter = props => {
|
||||
const context = useContext(TodoContext);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={context.clear()}>Clear Completed</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
11
step2-04/exercise/index.html
Normal file
11
step2-04/exercise/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!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>
|
||||
4
step2-04/exercise/src/TodoContext.ts
Normal file
4
step2-04/exercise/src/TodoContext.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
// The typing forces us to put something inside createContext(); start with undefined
|
||||
export const TodoContext = React.createContext(undefined);
|
||||
99
step2-04/exercise/src/components/TodoApp.tsx
Normal file
99
step2-04/exercise/src/components/TodoApp.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from '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 { TodoContext } from '../TodoContext';
|
||||
|
||||
let index = 0;
|
||||
|
||||
export class TodoApp extends React.Component<any, Store> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
todos: {},
|
||||
filter: 'all'
|
||||
};
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<TodoContext.Provider
|
||||
value={{
|
||||
...this.state,
|
||||
addTodo: this._addTodo,
|
||||
remove: this._remove,
|
||||
complete: this._complete,
|
||||
clear: this._clear,
|
||||
setFilter: this._setFilter,
|
||||
edit: this._edit
|
||||
}}
|
||||
>
|
||||
<Stack horizontalAlign="center">
|
||||
<Stack style={{ width: 400 }} gap={25}>
|
||||
<TodoHeader />
|
||||
<TodoList />
|
||||
<TodoFooter />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</TodoContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
private _addTodo = label => {
|
||||
const { todos } = this.state;
|
||||
const id = index++;
|
||||
|
||||
this.setState({
|
||||
todos: { ...todos, [id]: { label } }
|
||||
});
|
||||
};
|
||||
|
||||
private _remove = id => {
|
||||
const newTodos = { ...this.state.todos };
|
||||
delete newTodos[id];
|
||||
|
||||
this.setState({
|
||||
todos: newTodos
|
||||
});
|
||||
};
|
||||
|
||||
private _complete = id => {
|
||||
const newTodos = { ...this.state.todos };
|
||||
newTodos[id].completed = !newTodos[id].completed;
|
||||
|
||||
this.setState({
|
||||
todos: newTodos
|
||||
});
|
||||
};
|
||||
|
||||
private _edit = (id, label) => {
|
||||
const newTodos = { ...this.state.todos };
|
||||
newTodos[id] = { ...newTodos[id], label };
|
||||
|
||||
this.setState({
|
||||
todos: newTodos
|
||||
});
|
||||
};
|
||||
|
||||
private _clear = () => {
|
||||
const { todos } = this.state;
|
||||
const newTodos = {};
|
||||
|
||||
Object.keys(this.state.todos).forEach(id => {
|
||||
if (!todos[id].completed) {
|
||||
newTodos[id] = todos[id];
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({
|
||||
todos: newTodos
|
||||
});
|
||||
};
|
||||
|
||||
private _setFilter = filter => {
|
||||
this.setState({
|
||||
filter: filter
|
||||
});
|
||||
};
|
||||
}
|
||||
17
step2-04/exercise/src/components/TodoFooter.tsx
Normal file
17
step2-04/exercise/src/components/TodoFooter.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { DefaultButton, Stack, Text } from 'office-ui-fabric-react';
|
||||
import { TodoContext } from '../TodoContext';
|
||||
|
||||
export const TodoFooter = () => {
|
||||
const context = useContext(TodoContext);
|
||||
const itemCount = Object.keys(context.todos).filter(id => !context.todos[id].completed).length;
|
||||
|
||||
return (
|
||||
<Stack horizontal horizontalAlign="space-between">
|
||||
<Text>
|
||||
{itemCount} item{itemCount === 1 ? '' : 's'} left
|
||||
</Text>
|
||||
<DefaultButton onClick={() => context.clear()}>Clear Completed</DefaultButton>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
64
step2-04/exercise/src/components/TodoHeader.tsx
Normal file
64
step2-04/exercise/src/components/TodoHeader.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Pivot, PivotItem, TextField, PrimaryButton } from 'office-ui-fabric-react';
|
||||
import { FilterTypes } from '../store';
|
||||
import { TodoContext } from '../TodoContext';
|
||||
|
||||
interface TodoHeaderState {
|
||||
labelInput: string;
|
||||
}
|
||||
|
||||
export class TodoHeader extends React.Component<{}, TodoHeaderState> {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
this.state = { labelInput: undefined };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Stack gap={10}>
|
||||
<Stack horizontal horizontalAlign="center">
|
||||
<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}
|
||||
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.context.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.context.setFilter(item.props.headerText as FilterTypes);
|
||||
};
|
||||
}
|
||||
|
||||
TodoHeader.contextType = TodoContext;
|
||||
21
step2-04/exercise/src/components/TodoList.tsx
Normal file
21
step2-04/exercise/src/components/TodoList.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { Stack } from 'office-ui-fabric-react';
|
||||
import { TodoListItem } from './TodoListItem';
|
||||
import { Store, FilterTypes } from '../store';
|
||||
import { TodoContext } from '../TodoContext';
|
||||
|
||||
export const TodoList = () => {
|
||||
const context = useContext(TodoContext);
|
||||
const { filter, todos } = context;
|
||||
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>
|
||||
);
|
||||
};
|
||||
76
step2-04/exercise/src/components/TodoListItem.tsx
Normal file
76
step2-04/exercise/src/components/TodoListItem.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react';
|
||||
import { TodoContext } from '../TodoContext';
|
||||
|
||||
interface TodoListItemProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
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 { id } = this.props;
|
||||
const { todos, complete, remove } = this.context;
|
||||
|
||||
const item = todos[id];
|
||||
|
||||
return (
|
||||
<Stack horizontal verticalAlign="center" horizontalAlign="space-between">
|
||||
{!this.state.editing && (
|
||||
<>
|
||||
<Checkbox label={item.label} checked={item.completed} onChange={() => complete(id)} />
|
||||
<div>
|
||||
<IconButton iconProps={{ iconName: 'Edit' }} onClick={this.onEdit} />
|
||||
<IconButton iconProps={{ iconName: 'Cancel' }} onClick={() => 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>
|
||||
);
|
||||
}
|
||||
|
||||
private onEdit = () => {
|
||||
const { id } = this.props;
|
||||
const { todos } = this.context;
|
||||
const { label } = todos[id];
|
||||
|
||||
this.setState({
|
||||
editing: true,
|
||||
editLabel: this.state.editLabel || label
|
||||
});
|
||||
};
|
||||
|
||||
private onDoneEdit = () => {
|
||||
this.context.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 });
|
||||
};
|
||||
}
|
||||
|
||||
TodoListItem.contextType = TodoContext;
|
||||
10
step2-04/exercise/src/index.tsx
Normal file
10
step2-04/exercise/src/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { TodoApp } from './components/TodoApp';
|
||||
import { initializeIcons } from '@uifabric/icons';
|
||||
|
||||
// Initializes the UI Fabric icons that we can use
|
||||
// Choose one from this list: https://developer.microsoft.com/en-us/fabric#/styles/icons
|
||||
initializeIcons();
|
||||
|
||||
ReactDOM.render(<TodoApp />, document.getElementById('app'));
|
||||
14
step2-04/exercise/src/store/index.ts
Normal file
14
step2-04/exercise/src/store/index.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -6,8 +6,12 @@ If you still have the app running from a previous step, stop it with `ctrl+c`. S
|
||||
|
||||
1. First, take a look at the store interface in `exercise/src/store/index.ts`. Note that the `Store` interface has two keys: `todos` and `filter`. We'll concentrate on `todos`, which is an object where the keys are string IDs and the values are of a `TodoItem` type.
|
||||
|
||||
2. Open `exercise/src/reducers/pureFunctions.ts` and fill in the missing function bodies.
|
||||
2. Open `exercise/src/reducers/index.ts` and fill in the missing case statements for the switch on `action.type`.
|
||||
|
||||
3. Open `exercise/src/reducers/index.ts` and fill in the missing case statements for the switch on `action.type`.
|
||||
3. Open `exercise/src/index.tsx` and write separate dispatch calls.
|
||||
|
||||
4. Open `exercise/src/reducers/pureFunctions.spec.ts` and implement tests for the functions you wrote for `remove`, `complete`, and `clear`.
|
||||
4. Take a look what is written in the console (F12 on PC, cmd-option-I on Mac).
|
||||
|
||||
5. Install the [Chrome](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) or [Firefox](https://addons.mozilla.org/en-US/firefox/addon/reduxdevtools/) extensions
|
||||
|
||||
6. Observe the state changes, try doing "time travel"
|
||||
|
||||
@@ -5,11 +5,7 @@ import { composeWithDevTools } from 'redux-devtools-extension';
|
||||
|
||||
const store = createStore(reducer, {}, composeWithDevTools());
|
||||
|
||||
store.dispatch(actions.addTodo('hello'));
|
||||
|
||||
let action = actions.addTodo('world');
|
||||
store.dispatch(action);
|
||||
|
||||
store.dispatch(actions.remove(action.id));
|
||||
// TODO: try doing some store.dispatch() calls here
|
||||
// HINT: remember to use the functions inside "actions" object
|
||||
|
||||
console.log(store.getState());
|
||||
|
||||
@@ -6,7 +6,7 @@ export const todosReducer = createReducer<Store['todos']>(
|
||||
{},
|
||||
{
|
||||
addTodo(state, action) {
|
||||
state[action.id] = { label: action.label, completed: false };
|
||||
// TODO: implement this reducer
|
||||
},
|
||||
|
||||
remove(state, action) {
|
||||
@@ -14,10 +14,6 @@ export const todosReducer = createReducer<Store['todos']>(
|
||||
},
|
||||
|
||||
clear(state, action) {
|
||||
state[action.id].completed = !state[action.id].completed;
|
||||
},
|
||||
|
||||
complete(state, action) {
|
||||
Object.keys(state).forEach(key => {
|
||||
if (state[key].completed) {
|
||||
delete state[key];
|
||||
@@ -25,8 +21,12 @@ export const todosReducer = createReducer<Store['todos']>(
|
||||
});
|
||||
},
|
||||
|
||||
complete(state, action) {
|
||||
// TODO: implement this reducer
|
||||
},
|
||||
|
||||
edit(state, action) {
|
||||
state[action.id].label = action.label;
|
||||
// TODO: implement this reducer
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user