From eb1e1ebf98d3da770e0a653aab91e2f0b2b6f3bf Mon Sep 17 00:00:00 2001 From: flacki Date: Sat, 2 Mar 2019 23:30:38 +0100 Subject: [PATCH 01/17] Fixed typo in documentation --- step1-04/demo/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/step1-04/demo/README.md b/step1-04/demo/README.md index 2ab9eec..939f92b 100644 --- a/step1-04/demo/README.md +++ b/step1-04/demo/README.md @@ -113,7 +113,7 @@ constructor(props) { #### Using [object destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Object_destructuring) for props and state -Both `props` are `state` are JavaScript objects. They have a bunch of key/value pairs in them which you can access via `this.props.foo` or `this.state.bar`. Sometimes they have MANY values inside of them which you need access to. You could do this: +Both `props` and `state` are JavaScript objects. They have a bunch of key/value pairs in them which you can access via `this.props.foo` or `this.state.bar`. Sometimes they have MANY values inside of them which you need access to. You could do this: ```js let cat = this.props.cat; From b91914e1d8df5836f16236a8c8cc16bb422d675b Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 2 Mar 2019 20:57:25 -0800 Subject: [PATCH 02/17] overhauling steps 4-6, part 1 --- archived/jest/demo/README.md | 128 +++++++++++++ archived/jest/demo/index.html | 15 ++ .../jest}/demo/src/TestMe.spec.tsx | 0 .../jest}/demo/src/TestMe.tsx | 0 .../jest}/demo/src/index.spec.tsx | 0 {step2-04 => archived/jest}/demo/src/index.ts | 0 .../jest}/demo/src/multiply.ts | 0 .../jest}/exercise/README.md | 0 .../jest}/exercise/index.html | 0 .../jest}/exercise/src/TestMe.spec.tsx | 0 .../jest}/exercise/src/TestMe.tsx | 0 .../jest}/exercise/src/index.ts | 0 .../jest}/exercise/src/stack.spec.ts | 0 .../jest}/exercise/src/stack.ts | 0 assets/flux.png | Bin 27326 -> 51508 bytes assets/todo-components.png | Bin 0 -> 55460 bytes markdownReadme/src/index.js | 53 ------ step2-03/demo/src/components/TodoHeader.tsx | 6 +- step2-04/demo/README.md | 178 ++++++++---------- step2-04/demo/index.html | 6 +- step2-05/demo/README.md | 131 +++++++------ 21 files changed, 296 insertions(+), 221 deletions(-) create mode 100644 archived/jest/demo/README.md create mode 100644 archived/jest/demo/index.html rename {step2-04 => archived/jest}/demo/src/TestMe.spec.tsx (100%) rename {step2-04 => archived/jest}/demo/src/TestMe.tsx (100%) rename {step2-04 => archived/jest}/demo/src/index.spec.tsx (100%) rename {step2-04 => archived/jest}/demo/src/index.ts (100%) rename {step2-04 => archived/jest}/demo/src/multiply.ts (100%) rename {step2-04 => archived/jest}/exercise/README.md (100%) rename {step2-04 => archived/jest}/exercise/index.html (100%) rename {step2-04 => archived/jest}/exercise/src/TestMe.spec.tsx (100%) rename {step2-04 => archived/jest}/exercise/src/TestMe.tsx (100%) rename {step2-04 => archived/jest}/exercise/src/index.ts (100%) rename {step2-04 => archived/jest}/exercise/src/stack.spec.ts (100%) rename {step2-04 => archived/jest}/exercise/src/stack.ts (100%) create mode 100644 assets/todo-components.png delete mode 100644 markdownReadme/src/index.js diff --git a/archived/jest/demo/README.md b/archived/jest/demo/README.md new file mode 100644 index 0000000..60467a9 --- /dev/null +++ b/archived/jest/demo/README.md @@ -0,0 +1,128 @@ +# Step 2.4: Testing TypeScript code with Jest (Demo) + +[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) + +[Jest](https://jestjs.io/) is a test framework made by Facebook and is very popular in the React and wider JS ecosystems. + +In this exercise, we will work on implementing simple unit tests using Jest. + +## Jest Features + +- Multi-threaded and isolated test runner +- Provides a fake browser-like environment if needed (window, document, DOM, etc) using jsdom +- Snapshots: Jest can create text-based snapshots of rendered components. These snapshots can be checked in and show API or large object changes alongside code changes in pull requests. +- Code coverage is integrated (`--coverage`) +- Very clear error messages showing where a test failure occurred + +## How to use Jest + +- Using `create-react-app` or other project generators, Jest should already be pre-configured. Running `npm test` usually will trigger it! +- A `jest.config.js` file is used for configuration +- `jsdom` might not have enough API from real browsers, for those cases, polyfills are required. Place these inside `jest.setup.js` and hook up the setup file in `jest.config.js` +- in order to use `enzyme` library to test React Components, more config bits are needed inside `jest.setup.js` + +## What does a test look like? + +```ts +// describe(), it() and expect() are globally exported, so they don't need to be imported when jest runs these tests +describe('Something to be tested', () => { + it('should describe the behavior', () => { + expect(true).toBe(true); + }); +}); +``` + +## Testing React components using Enzyme + +[Enzyme](https://airbnb.io/enzyme/) is made by Airbnb and provides utilities to help test React components. + +In a real app using ReactDOM, the top-level component will be rendered on the page using `ReactDOM.render()`. Enzyme provides a lighter-weight `mount()` function which is usually adequate for testing purposes. + +`mount()` returns a wrapper that can be inspected and provides functionality like `find()`, simulating clicks, etc. + +The following code demonstrates how Enzyme can be used to help test React components. + +```jsx +import React from 'react'; +import { mount } from 'enzyme'; +import { TestMe } from './TestMe'; + +describe('TestMe Component', () => { + it('should have a non-clickable component when the original InnerMe is clicked', () => { + const wrapper = mount(); + wrapper.find('#innerMe').simulate('click'); + expect(wrapper.find('#innerMe').text()).toBe('Clicked'); + }); +}); + +describe('Foo Component Tests', () => { + it('allows us to set props', () => { + const wrapper = mount(); + expect(wrapper.props().bar).toBe('baz'); + wrapper.setProps({ bar: 'foo' }); + expect(wrapper.props().bar).toBe('foo'); + + wrapper.find('button').simulate('click'); + }); +}); +``` + +## Advanced topics + +### Mocking + +Mocking functions is a large part of what makes Jest a powerful testing library. Jest actually intercepts the module loading process in Node.js, allowing it to mock entire modules if needed. + +There are many ways to mock, as you'd imagine in a language as flexible as JS. We only look at the simplest case, but there's a lot of depth here. + +To mock a function: + +```ts +it('some test function', () => { + const mockCallback = jest.fn(x => 42 + x); + mockCallback(1); + mockCallback(2); + expect(mockCallback).toHaveBeenCalledTimes(2); +}); +``` + +Read more about jest mocking [here](https://jestjs.io/docs/en/mock-functions.html). + +### Async Testing + +For testing async scenarios, the test runner needs some way to know when the scenario is finished. Jest tests can handle async scenarios using callbacks, promises, or async/await. + +```ts +// Callback +it('tests callback functions', (done) => { + setTimeout(() => { + done(); + }, 1000); +}); + +// Returning a promise +it('tests promise functions', () => { + return someFunctionThatReturnsPromise()); +}); + +// Async/await (recommended) +it('tests async functions', async () => { + expect(await someFunction()).toBe(5); +}); +``` + +# Demo + +## Jest basics + +In this repo, we can start an inner loop development of tests by running `npm test` from the root of the `frontend-bootcamp` folder. + +Take a look at code inside `demo/src`: + +1. `index.ts` exports a few functions for a counter as well as a function for squaring numbers. We'll use this last function to demonstrate how mocks work. + +2. `multiply.ts` is a contrived example of a function that is exported + +3. `index.spec.ts` is the test file + +Note how tests are re-run when either test files or source files under `src` are saved. diff --git a/archived/jest/demo/index.html b/archived/jest/demo/index.html new file mode 100644 index 0000000..92a9499 --- /dev/null +++ b/archived/jest/demo/index.html @@ -0,0 +1,15 @@ + + + + + + +
+
+ For this step, we look at unit testing. Run +
npm test
+ in the command line. +
+ + + diff --git a/step2-04/demo/src/TestMe.spec.tsx b/archived/jest/demo/src/TestMe.spec.tsx similarity index 100% rename from step2-04/demo/src/TestMe.spec.tsx rename to archived/jest/demo/src/TestMe.spec.tsx diff --git a/step2-04/demo/src/TestMe.tsx b/archived/jest/demo/src/TestMe.tsx similarity index 100% rename from step2-04/demo/src/TestMe.tsx rename to archived/jest/demo/src/TestMe.tsx diff --git a/step2-04/demo/src/index.spec.tsx b/archived/jest/demo/src/index.spec.tsx similarity index 100% rename from step2-04/demo/src/index.spec.tsx rename to archived/jest/demo/src/index.spec.tsx diff --git a/step2-04/demo/src/index.ts b/archived/jest/demo/src/index.ts similarity index 100% rename from step2-04/demo/src/index.ts rename to archived/jest/demo/src/index.ts diff --git a/step2-04/demo/src/multiply.ts b/archived/jest/demo/src/multiply.ts similarity index 100% rename from step2-04/demo/src/multiply.ts rename to archived/jest/demo/src/multiply.ts diff --git a/step2-04/exercise/README.md b/archived/jest/exercise/README.md similarity index 100% rename from step2-04/exercise/README.md rename to archived/jest/exercise/README.md diff --git a/step2-04/exercise/index.html b/archived/jest/exercise/index.html similarity index 100% rename from step2-04/exercise/index.html rename to archived/jest/exercise/index.html diff --git a/step2-04/exercise/src/TestMe.spec.tsx b/archived/jest/exercise/src/TestMe.spec.tsx similarity index 100% rename from step2-04/exercise/src/TestMe.spec.tsx rename to archived/jest/exercise/src/TestMe.spec.tsx diff --git a/step2-04/exercise/src/TestMe.tsx b/archived/jest/exercise/src/TestMe.tsx similarity index 100% rename from step2-04/exercise/src/TestMe.tsx rename to archived/jest/exercise/src/TestMe.tsx diff --git a/step2-04/exercise/src/index.ts b/archived/jest/exercise/src/index.ts similarity index 100% rename from step2-04/exercise/src/index.ts rename to archived/jest/exercise/src/index.ts diff --git a/step2-04/exercise/src/stack.spec.ts b/archived/jest/exercise/src/stack.spec.ts similarity index 100% rename from step2-04/exercise/src/stack.spec.ts rename to archived/jest/exercise/src/stack.spec.ts diff --git a/step2-04/exercise/src/stack.ts b/archived/jest/exercise/src/stack.ts similarity index 100% rename from step2-04/exercise/src/stack.ts rename to archived/jest/exercise/src/stack.ts diff --git a/assets/flux.png b/assets/flux.png index bf48f6fc9d9a3c72363056fcba8b24791c2e2929..8ca69342bb2648946f32dbd37258f1b888e16e3b 100644 GIT binary patch literal 51508 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 literal 27326 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 z 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 F{SUPr>ev7P diff --git a/assets/todo-components.png b/assets/todo-components.png new file mode 100644 index 0000000000000000000000000000000000000000..4b3ff26bf2a91b9a41f80d06b4ca0a9cb1cbcc45 GIT binary patch literal 55460 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 OyijJArgg?1@&5(c>+_@l literal 0 HcmV?d00001 diff --git a/markdownReadme/src/index.js b/markdownReadme/src/index.js deleted file mode 100644 index 1771fec..0000000 --- a/markdownReadme/src/index.js +++ /dev/null @@ -1,53 +0,0 @@ -import marked, { Renderer } from 'marked'; -import hljs from 'highlight.js/lib/highlight'; -import javascript from 'highlight.js/lib/languages/javascript'; -import typescript from 'highlight.js/lib/languages/typescript'; - -hljs.registerLanguage('javascript', javascript); -hljs.registerLanguage('typescript', typescript); - -async function run() { - const div = document.getElementById('markdownReadme'); - - // Create your custom renderer. - const renderer = new Renderer(); - renderer.code = (code, language) => { - // Check whether the given language is valid for highlight.js. - const validLang = !!(language && hljs.getLanguage(language)); - // Highlight only if the language is valid. - const highlighted = validLang ? hljs.highlight(language, code).value : code; - // Render the highlighted code with `hljs` class. - return `
${highlighted}
`; - }; - marked.setOptions({ renderer }); - - if (div) { - const response = await fetch(div.dataset['src'] || '../README.md'); - const markdownText = await response.text(); - div.innerHTML = marked(markdownText, { baseUrl: '../' }); - restoreScroll(div); - - div.addEventListener('scroll', evt => { - saveScroll(div); - }); - - window.addEventListener('resize', evt => { - saveScroll(div); - }); - } -} - -const scrollKey = `${window.location.pathname}_scrolltop`; - -function saveScroll(div) { - window.localStorage.setItem(scrollKey, String(div.scrollTop)); -} - -function restoreScroll(div) { - const scrollTop = window.localStorage.getItem(scrollKey); - if (scrollTop) { - div.scrollTop = parseInt(scrollTop); - } -} - -run(); diff --git a/step2-03/demo/src/components/TodoHeader.tsx b/step2-03/demo/src/components/TodoHeader.tsx index 3e432f2..e6b2b41 100644 --- a/step2-03/demo/src/components/TodoHeader.tsx +++ b/step2-03/demo/src/components/TodoHeader.tsx @@ -22,7 +22,7 @@ export class TodoHeader extends React.Component - todos - step2-03 demo + todos @@ -40,9 +40,7 @@ export class TodoHeader extends React.Component - - Add - + Add diff --git a/step2-04/demo/README.md b/step2-04/demo/README.md index 60467a9..3034d6e 100644 --- a/step2-04/demo/README.md +++ b/step2-04/demo/README.md @@ -1,128 +1,108 @@ -# Step 2.4: Testing TypeScript code with Jest (Demo) +# Step 2.4 - React Context (Demo) [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) -[Jest](https://jestjs.io/) is a test framework made by Facebook and is very popular in the React and wider JS ecosystems. +In this step, we describe some problems we encounter when creating a more complex application. -In this exercise, we will work on implementing simple unit tests using Jest. +We will solve these problems with the React Context API. The Context API consists of: -## Jest Features +1. Provider component +2. Consuming context from a Class Component +3. Consuming context from a Functional Component -- Multi-threaded and isolated test runner -- Provides a fake browser-like environment if needed (window, document, DOM, etc) using jsdom -- Snapshots: Jest can create text-based snapshots of rendered components. These snapshots can be checked in and show API or large object changes alongside code changes in pull requests. -- Code coverage is integrated (`--coverage`) -- Very clear error messages showing where a test failure occurred +--- -## How to use Jest +For a single component, React gives us a mental model like this: -- Using `create-react-app` or other project generators, Jest should already be pre-configured. Running `npm test` usually will trigger it! -- A `jest.config.js` file is used for configuration -- `jsdom` might not have enough API from real browsers, for those cases, polyfills are required. Place these inside `jest.setup.js` and hook up the setup file in `jest.config.js` -- in order to use `enzyme` library to test React Components, more config bits are needed inside `jest.setup.js` +``` +(props) => view; +``` -## What does a test look like? +In a real application, these functions are composed. It looks more like this: + +![](../../assets/todo-components.png) + +## 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. + +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, `` has this props interface: ```ts -// describe(), it() and expect() are globally exported, so they don't need to be imported when jest runs these tests -describe('Something to be tested', () => { - it('should describe the behavior', () => { - expect(true).toBe(true); - }); -}); +interface TodoListProps { + complete: (id: string) => void; + remove: (id: string) => void; + todos: Store['todos']; + filter: FilterTypes; + edit: (id: string, label: string) => void; +} ``` -## Testing React components using Enzyme +All of these props are not used, except to be passed down to a child Component, `TodoListItem`: -[Enzyme](https://airbnb.io/enzyme/) is made by Airbnb and provides utilities to help test React components. - -In a real app using ReactDOM, the top-level component will be rendered on the page using `ReactDOM.render()`. Enzyme provides a lighter-weight `mount()` function which is usually adequate for testing purposes. - -`mount()` returns a wrapper that can be inspected and provides functionality like `find()`, simulating clicks, etc. - -The following code demonstrates how Enzyme can be used to help test React components. - -```jsx -import React from 'react'; -import { mount } from 'enzyme'; -import { TestMe } from './TestMe'; - -describe('TestMe Component', () => { - it('should have a non-clickable component when the original InnerMe is clicked', () => { - const wrapper = mount(); - wrapper.find('#innerMe').simulate('click'); - expect(wrapper.find('#innerMe').text()).toBe('Clicked'); - }); -}); - -describe('Foo Component Tests', () => { - it('allows us to set props', () => { - const wrapper = mount(); - expect(wrapper.props().bar).toBe('baz'); - wrapper.setProps({ bar: 'foo' }); - expect(wrapper.props().bar).toBe('foo'); - - wrapper.find('button').simulate('click'); - }); -}); +```js + ``` -## Advanced topics +## Context API -### Mocking +Let's solve the first one with the Context API. A `context` is a special way for React to share data from components to their descendant children components without having to explicitly pass down through props at every level of the tree. -Mocking functions is a large part of what makes Jest a powerful testing library. Jest actually intercepts the module loading process in Node.js, allowing it to mock entire modules if needed. - -There are many ways to mock, as you'd imagine in a language as flexible as JS. We only look at the simplest case, but there's a lot of depth here. - -To mock a function: +We create a context by calling `createContext()` with some initial data: ```ts -it('some test function', () => { - const mockCallback = jest.fn(x => 42 + x); - mockCallback(1); - mockCallback(2); - expect(mockCallback).toHaveBeenCalledTimes(2); -}); +const TodoContext = React.createContext(); ``` -Read more about jest mocking [here](https://jestjs.io/docs/en/mock-functions.html). +Now that we have a `TodoContext` stuffed with some initial state, we will wrap `TodoApp` component with `TodoContext.Provider` so that it can provide data to all its children: -### Async Testing - -For testing async scenarios, the test runner needs some way to know when the scenario is finished. Jest tests can handle async scenarios using callbacks, promises, or async/await. - -```ts -// Callback -it('tests callback functions', (done) => { - setTimeout(() => { - done(); - }, 1000); -}); - -// Returning a promise -it('tests promise functions', () => { - return someFunctionThatReturnsPromise()); -}); - -// Async/await (recommended) -it('tests async functions', async () => { - expect(await someFunction()).toBe(5); -}); +```js +class TodoApp extends React.Component { + render() { + return ( + +
+ + + +
+
+ ); + } +} ``` -# Demo +Inside the children components, like the `` component, the value can be access from the component's `context` prop like this: -## Jest basics +```js +class TodoHeader extends React.Component { + render() { + // Step 1: use the context prop + return
Filter is {this.context.filter}
; + } +} -In this repo, we can start an inner loop development of tests by running `npm test` from the root of the `frontend-bootcamp` folder. +// Step 2: be sure to set the contextType property of the component class +TodoHeader.contextType = TodoContext; +``` -Take a look at code inside `demo/src`: +If you're using the functional component syntax, you can access the context with the `useContext()` function (we are using the function passed down inside the context, in this case): -1. `index.ts` exports a few functions for a counter as well as a function for squaring numbers. We'll use this last function to demonstrate how mocks work. - -2. `multiply.ts` is a contrived example of a function that is exported - -3. `index.spec.ts` is the test file - -Note how tests are re-run when either test files or source files under `src` are saved. +```js +const TodoFooter = props => { + const context = useContext(TodoContext); + return ( +
+ +
+ ); +}; +``` diff --git a/step2-04/demo/index.html b/step2-04/demo/index.html index 92a9499..e848070 100644 --- a/step2-04/demo/index.html +++ b/step2-04/demo/index.html @@ -5,11 +5,7 @@
-
- For this step, we look at unit testing. Run -
npm test
- in the command line. -
+
diff --git a/step2-05/demo/README.md b/step2-05/demo/README.md index 71e8ce9..35ad1b7 100644 --- a/step2-05/demo/README.md +++ b/step2-05/demo/README.md @@ -1,101 +1,112 @@ -# Step 2.5 - Redux: Reducers (Demo) +# Step 2.5 - Redux: The Store (Demo) [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) -## Flux +In this step, we will look at solving the problems of complex application (as mentioned in Step 4) with a library called Redux. -Ideally React gives us a mental model of: +1. Introduction to Redux +2. Creating the Redux store +3. Writing reducers +4. Dispatching actions -``` -f(data) => view -``` +--- -We can pass shared state data down to components using props and update it using callbacks, but as an application grows larger, passing around callbacks becomes unwieldy and the state data flow becomes hard to understand. To keep the application maintainable, we need a well-defined pattern for sharing and updating application state. +As a reminder, the problem that we want to address are: -Facebook invented the [Flux](https://facebook.github.io/flux/) architectural pattern to solve this shared state issue. [Redux](https://redux.js.org/) is an implementation of Flux. +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. +2. There is a lack of coordination of changes that can happen to the data -Redux is used inside many large, complex applications because of its clarity and predictability. It is easy to debug and easily extensible via its middleware architecture. In this lesson, we'll explore the heart of how Redux manages state. - -Redux expects the data (store) to be a singleton state tree. It listens for messages to manipulate the state and passes updates down to views. +Redux is an implementation of the Flux architectural pattern: ![Flux Diagram](../assets/flux.png) ### View -A view is a React component that consumes the store as its data. There is a special way Redux maps data from the state tree into the different React components. The components will know to re-render when these bits of state are changed. +A view is a React component that consumes the store as its data. ### Action -[Actions](https://redux.js.org/basics/actions) are messages that represent some event, such as a user's action or a network request. With the aid of *reducers*, they affect the overall state. +[Actions](https://redux.js.org/basics/actions) are messages that represent some event, such as a user's action or a network request. With the aid of _reducers_, they affect the overall state. ### Store -The [store](https://redux.js.org/basics/store) contains a singleton state tree. The state tree is immutable and needs to be re-created at every action. This helps connected views easily to know when to update by allowing them to do a simple reference comparison rather than a deep comparison. You can think of each state as a snapshot of the app at that point in time. +The [store](https://redux.js.org/basics/store) consists of a **state tree**, a **dispatcher**, and **reducers**. -### Dispatcher +1. A state tree is a **singleton**, **serializable**, **immutable** json data. It is updated from one snapshot to another through `reducers`. -There is a single [dispatcher](https://redux.js.org/basics/data-flow), provided by the store. It simply informs the store of all the actions that need to be performed. Additional middleware (explained later) can be applied to the store, and the dispatcher's job is to dispatch the message through all the middleware layers. +2. A dispatcher accepts actions passing them to the reducers. -### Reducers +3. Reducers are functions that take in the current state tree and an action, producing the next snapshot of the state tree. -Redux uses [reducers](https://redux.js.org/basics/reducers) to manage state changes. This name comes from the "reducer" function passed to `Array.reduce()`. - -A Redux reducer is a simple **pure function** (no side effects). Its only job is to transform the state from one immutable snapshot to another. It takes state + an action message as input, makes a modified copy of the state based on the action message type and payload, and returns the new state. (Reducers [should not modify](https://redux.js.org/introduction/three-principles#state-is-read-only) the previous state.) - -**Mental Model**: Think of a reducer as part of the store. It should have no side effects and only define how data changes from one state to the next given action messages. - -### Advanced: Middleware - -From the [documentation site](https://redux.js.org/advanced/middleware): - -> Redux middleware provides a third-party extension point between dispatching an action, and the moment it reaches the reducer. - -We won't be covering middleware much in these lessons since it's a more advanced topic. - -## Getting started with Redux - -We begin the journey into Redux by looking at the store. The store consists of several parts: - -1. **State/data** - We represent this both with an initial state and with a TypeScript interface. -2. **Reducers** - Responsible for reacting to action messages to change from one state to the next. -3. **Dispatcher** - There should be only one dispatcher, which is exported by the store. We'll look at this in Step 6! - -### Create store +# Creating the Redux store The [`createStore()`](https://redux.js.org/api/createstore) function is provided by Redux and can take in several arguments. The simplest form just takes in reducers. ```ts -const store = createStore(reducer); +const store = createStore(reducer, initialState); ``` -`createStore()` can also take in an initial state. There are two common sources for the initial state: +`createStore()` creates a store with a reducer, and some initial state. -1. Load initial data from a server -2. Load data that is generated by a server-side rendering environment +# Writing Reducers + +We will write our reducers with the help of some utilities from `redux-starter-kit`. Here is how we will write our reducers: + +## 1. Organize reducers according to the keys of the state tree object: ```ts -const store = createStore(reducer, { - /* the initial state */ +import { createReducer } from 'redux-starter-kit'; + +const todosReducer = createReducer({}, { + // first argument is the initial state + // second argument is an object where the keys corresponds to the "action.type" + addTodo: (state, action) => ... }); + +const filterReducer = createReducer('all', { + setFilter: (state, action) => ... +}); + +const reducer = combineReducer({ + todos: todosReducer, + filter: filterReducer +}) ``` -`createStore()` can take a third argument that injects middleware, but we won't use this until later. +## 2. Write the reducers with mutables. -### Reducers - -Remember that the [reducers are **pure**](https://redux.js.org/introduction/three-principles#changes-are-made-with-pure-functions). Pure functions have no side effects. They always return the same output given the same input (idempotent). They are easily testable. - -Reducers look at the action's message to decide what to do to the state. A convention established in the Flux community is that the action message (payload) should include a `type` key. Another convention is using switch statements against the `type` key to trigger further reducer functions. +`createReducer()` will automatically translate all the mutations to the state into immutable snapshots (!!!!!): ```ts -function reducer(state: Store['todos'], payload: any): Store['todos'] { - switch (payload.type) { - case 'addTodo': - return addTodo(state, payload.id, payload.label); +const todosReducer = createReducer( + {}, + { + // first argument is the initial state + // second argument is an object where the keys corresponds to the "action.type" + addTodo: (state, action) => { + state[action.id] = { label: action.label, completed: false }; + } } - - return state; -} +); ``` -In the demo and exercises for this step, I separated the pure and reducer functions into different files to make it cleaner. The tests inside `pureFunctions.spec.ts` should describe the behavior of the individual functions. They are easy to follow and easy to write. +# Dispatching Actions + +Dispatching action will pass the action and the current state to the _reducers_. The root _reducer_ will produce a new snapshot for the entire state tree. We can inspect the affected snapshot with the help of `getState()`. + +```ts +const store = createStore(reducer, initialState); +store.dispatch({ type: 'addTodo', label: 'hello' }); +store.dispatch({ type: 'addTodo', label: 'world' }); +console.log(store.getState()); +``` + +Creating these action messages by hand is tedious, so we use action creators to do that: + +```ts +const actions = { + addTodo = (label: string) => ({ label, id: nextId(), completed: false }) +}; + +store.dispatch(actions.addTodo('hello')); +``` From 4be1dcdae93a3d93ccf213cb8280c821a0361cdb Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 2 Mar 2019 21:23:22 -0800 Subject: [PATCH 03/17] context demo works --- index.html | 2 +- package-lock.json | 22 ++--- package.json | 5 +- step2-02/demo/src/components/TodoApp.tsx | 1 + step2-04/demo/README.md | 11 +-- step2-04/demo/src/TodoContext.ts | 4 + step2-04/demo/src/components/TodoApp.tsx | 99 +++++++++++++++++++ step2-04/demo/src/components/TodoFooter.tsx | 17 ++++ step2-04/demo/src/components/TodoHeader.tsx | 64 ++++++++++++ step2-04/demo/src/components/TodoList.tsx | 21 ++++ step2-04/demo/src/components/TodoListItem.tsx | 76 ++++++++++++++ step2-04/demo/src/index.tsx | 10 ++ step2-04/demo/src/store/index.ts | 14 +++ 13 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 step2-04/demo/src/TodoContext.ts create mode 100644 step2-04/demo/src/components/TodoApp.tsx create mode 100644 step2-04/demo/src/components/TodoFooter.tsx create mode 100644 step2-04/demo/src/components/TodoHeader.tsx create mode 100644 step2-04/demo/src/components/TodoList.tsx create mode 100644 step2-04/demo/src/components/TodoListItem.tsx create mode 100644 step2-04/demo/src/index.tsx create mode 100644 step2-04/demo/src/store/index.ts diff --git a/index.html b/index.html index 6fbe1cc..cfcb13e 100644 --- a/index.html +++ b/index.html @@ -109,7 +109,7 @@
  • -
  • - -
  • -
  • - -
  • - +
  • diff --git a/step2-07/demo/README.md b/step2-07/demo/README.md index a200c14..f78aede 100644 --- a/step2-07/demo/README.md +++ b/step2-07/demo/README.md @@ -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! -### `` 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 [``](https://react-redux.js.org/api/provider). A `` 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 ( - -
    Hello World!
    -
    - ); -}; -``` - -### `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 `` component. - -```js -const OldComponent = props => { - return
    {props.foo}
    ; -}; - -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 { - 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 { - 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()` diff --git a/step2-07/demo/src/actions/index.ts b/step2-07/demo/src/actions/index.ts index 4a067b9..c88015d 100644 --- a/step2-07/demo/src/actions/index.ts +++ b/step2-07/demo/src/actions/index.ts @@ -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]); + }; + } }; diff --git a/step2-07/demo/src/components/TodoApp.tsx b/step2-07/demo/src/components/TodoApp.tsx index 83464c0..4425e1d 100644 --- a/step2-07/demo/src/components/TodoApp.tsx +++ b/step2-07/demo/src/components/TodoApp.tsx @@ -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 ( - - - - - - - + + + + + - + ); }; diff --git a/step2-07/demo/src/components/TodoFooter.tsx b/step2-07/demo/src/components/TodoFooter.tsx index d876d41..b92c239 100644 --- a/step2-07/demo/src/components/TodoFooter.tsx +++ b/step2-07/demo/src/components/TodoFooter.tsx @@ -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 ( {itemCount} item{itemCount === 1 ? '' : 's'} left - props.clear()}>Clear Completed + dispatch(actionsWithService.clear())}>Clear Completed ); }; - -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 }; diff --git a/step2-07/demo/src/components/TodoHeader.tsx b/step2-07/demo/src/components/TodoHeader.tsx index 171cf06..871febc 100644 --- a/step2-07/demo/src/components/TodoHeader.tsx +++ b/step2-07/demo/src/components/TodoHeader.tsx @@ -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 { - 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 { return ( - todos - step2-07 demo + todos - + ({ + ...(props.focused && { + field: { + backgroundColor: '#c7e0f4' + } + }) + })} + /> Add + + + + + + ); } 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, 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; diff --git a/step2-07/demo/src/components/TodoList.tsx b/step2-07/demo/src/components/TodoList.tsx index f9781ae..b577baa 100644 --- a/step2-07/demo/src/components/TodoList.tsx +++ b/step2-07/demo/src/components/TodoList.tsx @@ -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 ( @@ -20,18 +17,3 @@ const TodoList = (props: TodoListProps) => { ); }; - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return {}; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoList); - -export { component as TodoList }; diff --git a/step2-07/demo/src/components/TodoListItem.tsx b/step2-07/demo/src/components/TodoListItem.tsx index 24ab756..71d9b1e 100644 --- a/step2-07/demo/src/components/TodoListItem.tsx +++ b/step2-07/demo/src/components/TodoListItem.tsx @@ -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 { +interface TodoListItemState { + editing: boolean; + editLabel: string; +} + +export class TodoListItem extends React.Component { + 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 ( - complete(id)} /> -
    - remove(id)} /> -
    + {!this.state.editing && ( + <> + dispatch(actionsWithService.complete(id))} /> +
    + + dispatch(actionsWithService.remove(id))} /> +
    + + )} + + {this.state.editing && ( + + + + + + Save + + + )}
    ); } -} -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, 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; diff --git a/step2-07/demo/src/index.tsx b/step2-07/demo/src/index.tsx index ce6f3f0..eadd502 100644 --- a/step2-07/demo/src/index.tsx +++ b/step2-07/demo/src/index.tsx @@ -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( - - - , - document.getElementById('app') -); + ReactDOM.render( + + + , + document.getElementById('app') + ); +})(); diff --git a/step2-07/demo/src/reducers/index.ts b/step2-07/demo/src/reducers/index.ts index 6eed3c5..757ac54 100644 --- a/step2-07/demo/src/reducers/index.ts +++ b/step2-07/demo/src/reducers/index.ts @@ -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( + {}, + { + 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('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 +}); diff --git a/step2-07/demo/src/reducers/pureFunctions.spec.ts b/step2-07/demo/src/reducers/pureFunctions.spec.ts deleted file mode 100644 index b3815cf..0000000 --- a/step2-07/demo/src/reducers/pureFunctions.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { addTodo, complete } from './pureFunctions'; -import { Store } from '../store'; - -describe('TodoApp reducers', () => { - it('can add an item', () => { - const state = {}; - - const newState = addTodo(state, '0', 'item1'); - - const keys = Object.keys(newState); - - expect(newState).not.toBe(state); - expect(keys.length).toBe(1); - expect(newState[keys[0]].label).toBe('item1'); - expect(newState[keys[0]].completed).toBeFalsy(); - }); - - it('can complete an item', () => { - const state = {}; - - let newState = addTodo(state, '0', 'item1'); - - const key = Object.keys(newState)[0]; - - newState = complete(newState, key); - - expect(newState[key].completed).toBeTruthy(); - }); -}); diff --git a/step2-07/demo/src/reducers/pureFunctions.ts b/step2-07/demo/src/reducers/pureFunctions.ts deleted file mode 100644 index e1954e5..0000000 --- a/step2-07/demo/src/reducers/pureFunctions.ts +++ /dev/null @@ -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; -} diff --git a/step2-09/demo/src/service/index.ts b/step2-07/demo/src/service/index.ts similarity index 100% rename from step2-09/demo/src/service/index.ts rename to step2-07/demo/src/service/index.ts diff --git a/step2-07/exercise/README.md b/step2-07/exercise/README.md deleted file mode 100644 index cc4b35f..0000000 --- a/step2-07/exercise/README.md +++ /dev/null @@ -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 `` with `` 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 diff --git a/step2-07/exercise/index.html b/step2-07/exercise/index.html deleted file mode 100644 index ee7d10d..0000000 --- a/step2-07/exercise/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - -
    -
    - - - diff --git a/step2-07/exercise/src/actions/index.ts b/step2-07/exercise/src/actions/index.ts deleted file mode 100644 index 4a067b9..0000000 --- a/step2-07/exercise/src/actions/index.ts +++ /dev/null @@ -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' }) -}; diff --git a/step2-07/exercise/src/components/TodoApp.tsx b/step2-07/exercise/src/components/TodoApp.tsx deleted file mode 100644 index 44f7452..0000000 --- a/step2-07/exercise/src/components/TodoApp.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - ); -}; diff --git a/step2-07/exercise/src/components/TodoFooter.tsx b/step2-07/exercise/src/components/TodoFooter.tsx deleted file mode 100644 index b97194a..0000000 --- a/step2-07/exercise/src/components/TodoFooter.tsx +++ /dev/null @@ -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 ( - - - {itemCount} item{itemCount === 1 ? '' : 's'} left - - props.clear()}>Clear Completed - - ); -}; - -/* -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 }; -*/ diff --git a/step2-07/exercise/src/components/TodoHeader.tsx b/step2-07/exercise/src/components/TodoHeader.tsx deleted file mode 100644 index 6a410af..0000000 --- a/step2-07/exercise/src/components/TodoHeader.tsx +++ /dev/null @@ -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 { - constructor(props: TodoHeaderProps) { - super(props); - this.state = { labelInput: undefined }; - } - - render() { - return ( - - - todos - step2-07 exercise - - - - - - - Add - - - ); - } - - private onAdd = () => { - this.props.addTodo(this.state.labelInput); - this.setState({ labelInput: undefined }); - }; - - private onChange = (evt: React.FormEvent, 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 }; - -*/ diff --git a/step2-07/exercise/src/components/TodoList.tsx b/step2-07/exercise/src/components/TodoList.tsx deleted file mode 100644 index f9781ae..0000000 --- a/step2-07/exercise/src/components/TodoList.tsx +++ /dev/null @@ -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 ( - - {filteredTodos.map(id => ( - - ))} - - ); -}; - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return {}; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoList); - -export { component as TodoList }; diff --git a/step2-07/exercise/src/components/TodoListItem.tsx b/step2-07/exercise/src/components/TodoListItem.tsx deleted file mode 100644 index 24ab756..0000000 --- a/step2-07/exercise/src/components/TodoListItem.tsx +++ /dev/null @@ -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 { - render() { - const { todos, id, complete, remove } = this.props; - const item = todos[id]; - - return ( - - complete(id)} /> -
    - remove(id)} /> -
    -
    - ); - } -} - -function mapStateToProps({ todos }: Store) { - return { - todos - }; -} - -function mapDispatchToProps(dispatch: any) { - return { - remove: (id: string) => dispatch(actions.remove(id)), - complete: (id: string) => dispatch(actions.complete(id)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoListItem); - -export { component as TodoListItem }; diff --git a/step2-07/exercise/src/index.tsx b/step2-07/exercise/src/index.tsx deleted file mode 100644 index f14f51c..0000000 --- a/step2-07/exercise/src/index.tsx +++ /dev/null @@ -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( - - - , - document.getElementById('app') -); diff --git a/step2-07/exercise/src/reducers/index.ts b/step2-07/exercise/src/reducers/index.ts deleted file mode 100644 index 6eed3c5..0000000 --- a/step2-07/exercise/src/reducers/index.ts +++ /dev/null @@ -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' - }; -} diff --git a/step2-07/exercise/src/reducers/pureFunctions.spec.ts b/step2-07/exercise/src/reducers/pureFunctions.spec.ts deleted file mode 100644 index b3815cf..0000000 --- a/step2-07/exercise/src/reducers/pureFunctions.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { addTodo, complete } from './pureFunctions'; -import { Store } from '../store'; - -describe('TodoApp reducers', () => { - it('can add an item', () => { - const state = {}; - - const newState = addTodo(state, '0', 'item1'); - - const keys = Object.keys(newState); - - expect(newState).not.toBe(state); - expect(keys.length).toBe(1); - expect(newState[keys[0]].label).toBe('item1'); - expect(newState[keys[0]].completed).toBeFalsy(); - }); - - it('can complete an item', () => { - const state = {}; - - let newState = addTodo(state, '0', 'item1'); - - const key = Object.keys(newState)[0]; - - newState = complete(newState, key); - - expect(newState[key].completed).toBeTruthy(); - }); -}); diff --git a/step2-07/exercise/src/reducers/pureFunctions.ts b/step2-07/exercise/src/reducers/pureFunctions.ts deleted file mode 100644 index e1954e5..0000000 --- a/step2-07/exercise/src/reducers/pureFunctions.ts +++ /dev/null @@ -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; -} diff --git a/step2-07/exercise/src/store/index.ts b/step2-07/exercise/src/store/index.ts deleted file mode 100644 index 221b5f4..0000000 --- a/step2-07/exercise/src/store/index.ts +++ /dev/null @@ -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; -} diff --git a/step2-08/demo/README.md b/step2-08/demo/README.md deleted file mode 100644 index 9b32cd9..0000000 --- a/step2-08/demo/README.md +++ /dev/null @@ -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? - -
    -It's okay. Don't be cry. - -
    - -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. diff --git a/step2-08/demo/index.html b/step2-08/demo/index.html deleted file mode 100644 index e848070..0000000 --- a/step2-08/demo/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - -
    -
    - - - diff --git a/step2-08/demo/src/actions/index.ts b/step2-08/demo/src/actions/index.ts deleted file mode 100644 index 0b9c43a..0000000 --- a/step2-08/demo/src/actions/index.ts +++ /dev/null @@ -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 }) -}; diff --git a/step2-08/demo/src/components/TodoApp.tsx b/step2-08/demo/src/components/TodoApp.tsx deleted file mode 100644 index 83464c0..0000000 --- a/step2-08/demo/src/components/TodoApp.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - ); -}; diff --git a/step2-08/demo/src/components/TodoFooter.tsx b/step2-08/demo/src/components/TodoFooter.tsx deleted file mode 100644 index 5805f34..0000000 --- a/step2-08/demo/src/components/TodoFooter.tsx +++ /dev/null @@ -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 ( - - - {itemCount} item{itemCount === 1 ? '' : 's'} left - - props.clear()}>Clear Completed - - ); -}; - -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 }; diff --git a/step2-08/demo/src/components/TodoHeader.tsx b/step2-08/demo/src/components/TodoHeader.tsx deleted file mode 100644 index ec5b351..0000000 --- a/step2-08/demo/src/components/TodoHeader.tsx +++ /dev/null @@ -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 { - constructor(props: TodoHeaderProps) { - super(props); - this.state = { labelInput: undefined }; - } - - render() { - return ( - - - todos - step2-08 demo - - - - - - - Add - - - - - - - - - ); - } - - private onAdd = () => { - this.props.addTodo(this.state.labelInput); - this.setState({ labelInput: undefined }); - }; - - private onChange = (evt: React.FormEvent, newValue: string) => { - this.setState({ labelInput: newValue }); - }; - - private onFilter = (item: PivotItem) => { - this.props.setFilter(item.props.headerText as FilterTypes); - }; -} - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return { - addTodo: (label: string) => dispatch(actions.addTodo(label)), - setFilter: (filter: FilterTypes) => dispatch(actions.setFilter(filter)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoHeader); - -export { component as TodoHeader }; diff --git a/step2-08/demo/src/components/TodoList.tsx b/step2-08/demo/src/components/TodoList.tsx deleted file mode 100644 index 2f33d89..0000000 --- a/step2-08/demo/src/components/TodoList.tsx +++ /dev/null @@ -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 ( - - {filteredTodos.map(id => ( - - ))} - - ); -}; - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return {}; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoList); - -export { component as TodoList }; diff --git a/step2-08/demo/src/components/TodoListItem.tsx b/step2-08/demo/src/components/TodoListItem.tsx deleted file mode 100644 index 24ab756..0000000 --- a/step2-08/demo/src/components/TodoListItem.tsx +++ /dev/null @@ -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 { - render() { - const { todos, id, complete, remove } = this.props; - const item = todos[id]; - - return ( - - complete(id)} /> -
    - remove(id)} /> -
    -
    - ); - } -} - -function mapStateToProps({ todos }: Store) { - return { - todos - }; -} - -function mapDispatchToProps(dispatch: any) { - return { - remove: (id: string) => dispatch(actions.remove(id)), - complete: (id: string) => dispatch(actions.complete(id)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoListItem); - -export { component as TodoListItem }; diff --git a/step2-08/demo/src/index.tsx b/step2-08/demo/src/index.tsx deleted file mode 100644 index ce6f3f0..0000000 --- a/step2-08/demo/src/index.tsx +++ /dev/null @@ -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( - - - , - document.getElementById('app') -); diff --git a/step2-08/demo/src/reducers/index.ts b/step2-08/demo/src/reducers/index.ts deleted file mode 100644 index 243e864..0000000 --- a/step2-08/demo/src/reducers/index.ts +++ /dev/null @@ -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( - {}, - { - addTodo, - remove, - clear, - complete - } -); - -const filterReducer = createReducer('all', { - setFilter -}); - -export const reducer = combineReducers({ - todos: todoReducer, - filter: filterReducer -}); diff --git a/step2-08/demo/src/reducers/pureFunctions.spec.ts b/step2-08/demo/src/reducers/pureFunctions.spec.ts deleted file mode 100644 index 7df158a..0000000 --- a/step2-08/demo/src/reducers/pureFunctions.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { addTodo, complete } from './pureFunctions'; -import { Store } from '../store'; - -describe('TodoApp reducers', () => { - it('can add an item', () => { - const state = {}; - - 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 = {}; - addTodo(state, { id: '0', label: 'item1' }); - complete(state, { id: '0' }); - expect(state['0'].completed).toBeTruthy(); - }); -}); diff --git a/step2-08/demo/src/reducers/pureFunctions.ts b/step2-08/demo/src/reducers/pureFunctions.ts deleted file mode 100644 index fa9db70..0000000 --- a/step2-08/demo/src/reducers/pureFunctions.ts +++ /dev/null @@ -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; -} diff --git a/step2-08/demo/src/store/index.ts b/step2-08/demo/src/store/index.ts deleted file mode 100644 index 221b5f4..0000000 --- a/step2-08/demo/src/store/index.ts +++ /dev/null @@ -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; -} diff --git a/step2-08/exercise/README.md b/step2-08/exercise/README.md deleted file mode 100644 index c51ba5a..0000000 --- a/step2-08/exercise/README.md +++ /dev/null @@ -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 diff --git a/step2-08/exercise/index.html b/step2-08/exercise/index.html deleted file mode 100644 index ee7d10d..0000000 --- a/step2-08/exercise/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - -
    -
    - - - diff --git a/step2-08/exercise/src/actions/index.ts b/step2-08/exercise/src/actions/index.ts deleted file mode 100644 index 0b9c43a..0000000 --- a/step2-08/exercise/src/actions/index.ts +++ /dev/null @@ -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 }) -}; diff --git a/step2-08/exercise/src/components/TodoApp.tsx b/step2-08/exercise/src/components/TodoApp.tsx deleted file mode 100644 index 83464c0..0000000 --- a/step2-08/exercise/src/components/TodoApp.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - ); -}; diff --git a/step2-08/exercise/src/components/TodoFooter.tsx b/step2-08/exercise/src/components/TodoFooter.tsx deleted file mode 100644 index 417c3e2..0000000 --- a/step2-08/exercise/src/components/TodoFooter.tsx +++ /dev/null @@ -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 ( - - - {itemCount} item{itemCount === 1 ? '' : 's'} left - - props.clear()}>Clear Completed - - ); -}; - -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 }; diff --git a/step2-08/exercise/src/components/TodoHeader.tsx b/step2-08/exercise/src/components/TodoHeader.tsx deleted file mode 100644 index c0de11c..0000000 --- a/step2-08/exercise/src/components/TodoHeader.tsx +++ /dev/null @@ -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 { - constructor(props: TodoHeaderProps) { - super(props); - this.state = { labelInput: undefined }; - } - - render() { - return ( - - - todos - step2-08 exercise - - - - - - - Add - - - - - - - - - ); - } - - private onAdd = () => { - this.props.addTodo(this.state.labelInput); - this.setState({ labelInput: undefined }); - }; - - private onChange = (evt: React.FormEvent, newValue: string) => { - this.setState({ labelInput: newValue }); - }; - - private onFilter = (item: PivotItem) => { - this.props.setFilter(item.props.headerText as FilterTypes); - }; -} - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return { - addTodo: (label: string) => dispatch(actions.addTodo(label)), - setFilter: (filter: FilterTypes) => dispatch(actions.setFilter(filter)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoHeader); - -export { component as TodoHeader }; diff --git a/step2-08/exercise/src/components/TodoList.tsx b/step2-08/exercise/src/components/TodoList.tsx deleted file mode 100644 index 2f33d89..0000000 --- a/step2-08/exercise/src/components/TodoList.tsx +++ /dev/null @@ -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 ( - - {filteredTodos.map(id => ( - - ))} - - ); -}; - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return {}; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoList); - -export { component as TodoList }; diff --git a/step2-08/exercise/src/components/TodoListItem.tsx b/step2-08/exercise/src/components/TodoListItem.tsx deleted file mode 100644 index 24ab756..0000000 --- a/step2-08/exercise/src/components/TodoListItem.tsx +++ /dev/null @@ -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 { - render() { - const { todos, id, complete, remove } = this.props; - const item = todos[id]; - - return ( - - complete(id)} /> -
    - remove(id)} /> -
    -
    - ); - } -} - -function mapStateToProps({ todos }: Store) { - return { - todos - }; -} - -function mapDispatchToProps(dispatch: any) { - return { - remove: (id: string) => dispatch(actions.remove(id)), - complete: (id: string) => dispatch(actions.complete(id)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoListItem); - -export { component as TodoListItem }; diff --git a/step2-08/exercise/src/index.tsx b/step2-08/exercise/src/index.tsx deleted file mode 100644 index ce6f3f0..0000000 --- a/step2-08/exercise/src/index.tsx +++ /dev/null @@ -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( - - - , - document.getElementById('app') -); diff --git a/step2-08/exercise/src/reducers/index.ts b/step2-08/exercise/src/reducers/index.ts deleted file mode 100644 index cfcd9a0..0000000 --- a/step2-08/exercise/src/reducers/index.ts +++ /dev/null @@ -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' - }; -} diff --git a/step2-08/exercise/src/reducers/pureFunctions.ts b/step2-08/exercise/src/reducers/pureFunctions.ts deleted file mode 100644 index 0392820..0000000 --- a/step2-08/exercise/src/reducers/pureFunctions.ts +++ /dev/null @@ -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; -} diff --git a/step2-08/exercise/src/store/index.ts b/step2-08/exercise/src/store/index.ts deleted file mode 100644 index 221b5f4..0000000 --- a/step2-08/exercise/src/store/index.ts +++ /dev/null @@ -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; -} diff --git a/step2-09/demo/README.md b/step2-09/demo/README.md deleted file mode 100644 index 6ab57b7..0000000 --- a/step2-09/demo/README.md +++ /dev/null @@ -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()` diff --git a/step2-09/demo/index.html b/step2-09/demo/index.html deleted file mode 100644 index e848070..0000000 --- a/step2-09/demo/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - -
    -
    - - - diff --git a/step2-09/demo/src/actions/index.ts b/step2-09/demo/src/actions/index.ts deleted file mode 100644 index d6ff2be..0000000 --- a/step2-09/demo/src/actions/index.ts +++ /dev/null @@ -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); - }; - } -}; diff --git a/step2-09/demo/src/components/TodoApp.tsx b/step2-09/demo/src/components/TodoApp.tsx deleted file mode 100644 index ef8f05b..0000000 --- a/step2-09/demo/src/components/TodoApp.tsx +++ /dev/null @@ -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 { - constructor(props) { - super(props); - this.state = { - todos: {}, - filter: 'all' - }; - } - render() { - const { filter, todos } = this.state; - return ( - - - - - - - - - - ); - } -} diff --git a/step2-09/demo/src/components/TodoFooter.tsx b/step2-09/demo/src/components/TodoFooter.tsx deleted file mode 100644 index 4a3f3c2..0000000 --- a/step2-09/demo/src/components/TodoFooter.tsx +++ /dev/null @@ -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 ( - - - {itemCount} item{itemCount === 1 ? '' : 's'} left - - props.clear()}>Clear Completed - - ); -}; - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return { - clear: () => dispatch(actionsWithService.clear()) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoFooter); - -export { component as TodoFooter }; diff --git a/step2-09/demo/src/components/TodoHeader.tsx b/step2-09/demo/src/components/TodoHeader.tsx deleted file mode 100644 index 943a6d0..0000000 --- a/step2-09/demo/src/components/TodoHeader.tsx +++ /dev/null @@ -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 { - constructor(props: TodoHeaderProps) { - super(props); - this.state = { labelInput: undefined }; - } - - render() { - return ( - - - todos - step2-09 demo - - - - - - - Add - - - - - - - - - ); - } - - private onAdd = () => { - this.props.addTodo(this.state.labelInput); - this.setState({ labelInput: undefined }); - }; - - private onChange = (evt: React.FormEvent, newValue: string) => { - this.setState({ labelInput: newValue }); - }; - - private onFilter = (item: PivotItem) => { - this.props.setFilter(item.props.headerText as FilterTypes); - }; -} - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return { - addTodo: (label: string) => dispatch(actionsWithService.addTodo(label)), - setFilter: (filter: FilterTypes) => dispatch(actions.setFilter(filter)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoHeader); - -export { component as TodoHeader }; diff --git a/step2-09/demo/src/components/TodoList.tsx b/step2-09/demo/src/components/TodoList.tsx deleted file mode 100644 index 2f33d89..0000000 --- a/step2-09/demo/src/components/TodoList.tsx +++ /dev/null @@ -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 ( - - {filteredTodos.map(id => ( - - ))} - - ); -}; - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return {}; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoList); - -export { component as TodoList }; diff --git a/step2-09/demo/src/components/TodoListItem.tsx b/step2-09/demo/src/components/TodoListItem.tsx deleted file mode 100644 index a2f8a7b..0000000 --- a/step2-09/demo/src/components/TodoListItem.tsx +++ /dev/null @@ -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 { - render() { - const { todos, id, complete, remove } = this.props; - const item = todos[id]; - - return ( - - complete(id)} /> -
    - remove(id)} /> -
    -
    - ); - } -} - -function mapStateToProps({ todos }: Store) { - return { - todos - }; -} - -function mapDispatchToProps(dispatch: any) { - return { - remove: (id: string) => dispatch(actionsWithService.remove(id)), - complete: (id: string) => dispatch(actionsWithService.complete(id)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoListItem); - -export { component as TodoListItem }; diff --git a/step2-09/demo/src/index.tsx b/step2-09/demo/src/index.tsx deleted file mode 100644 index 62932c1..0000000 --- a/step2-09/demo/src/index.tsx +++ /dev/null @@ -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( - - - , - document.getElementById('app') - ); -})(); diff --git a/step2-09/demo/src/reducers/index.ts b/step2-09/demo/src/reducers/index.ts deleted file mode 100644 index 243e864..0000000 --- a/step2-09/demo/src/reducers/index.ts +++ /dev/null @@ -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( - {}, - { - addTodo, - remove, - clear, - complete - } -); - -const filterReducer = createReducer('all', { - setFilter -}); - -export const reducer = combineReducers({ - todos: todoReducer, - filter: filterReducer -}); diff --git a/step2-09/demo/src/reducers/pureFunctions.spec.ts b/step2-09/demo/src/reducers/pureFunctions.spec.ts deleted file mode 100644 index 7df158a..0000000 --- a/step2-09/demo/src/reducers/pureFunctions.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { addTodo, complete } from './pureFunctions'; -import { Store } from '../store'; - -describe('TodoApp reducers', () => { - it('can add an item', () => { - const state = {}; - - 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 = {}; - addTodo(state, { id: '0', label: 'item1' }); - complete(state, { id: '0' }); - expect(state['0'].completed).toBeTruthy(); - }); -}); diff --git a/step2-09/demo/src/reducers/pureFunctions.ts b/step2-09/demo/src/reducers/pureFunctions.ts deleted file mode 100644 index fa9db70..0000000 --- a/step2-09/demo/src/reducers/pureFunctions.ts +++ /dev/null @@ -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; -} diff --git a/step2-09/demo/src/store/index.ts b/step2-09/demo/src/store/index.ts deleted file mode 100644 index 221b5f4..0000000 --- a/step2-09/demo/src/store/index.ts +++ /dev/null @@ -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; -} diff --git a/step2-09/exercise/README.md b/step2-09/exercise/README.md deleted file mode 100644 index 7c23822..0000000 --- a/step2-09/exercise/README.md +++ /dev/null @@ -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 diff --git a/step2-09/exercise/index.html b/step2-09/exercise/index.html deleted file mode 100644 index ee7d10d..0000000 --- a/step2-09/exercise/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - -
    -
    - - - diff --git a/step2-09/exercise/src/actions/index.ts b/step2-09/exercise/src/actions/index.ts deleted file mode 100644 index a1d3b48..0000000 --- a/step2-09/exercise/src/actions/index.ts +++ /dev/null @@ -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); - } -}; diff --git a/step2-09/exercise/src/components/TodoApp.tsx b/step2-09/exercise/src/components/TodoApp.tsx deleted file mode 100644 index ef8f05b..0000000 --- a/step2-09/exercise/src/components/TodoApp.tsx +++ /dev/null @@ -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 { - constructor(props) { - super(props); - this.state = { - todos: {}, - filter: 'all' - }; - } - render() { - const { filter, todos } = this.state; - return ( - - - - - - - - - - ); - } -} diff --git a/step2-09/exercise/src/components/TodoFooter.tsx b/step2-09/exercise/src/components/TodoFooter.tsx deleted file mode 100644 index 4a3f3c2..0000000 --- a/step2-09/exercise/src/components/TodoFooter.tsx +++ /dev/null @@ -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 ( - - - {itemCount} item{itemCount === 1 ? '' : 's'} left - - props.clear()}>Clear Completed - - ); -}; - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return { - clear: () => dispatch(actionsWithService.clear()) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoFooter); - -export { component as TodoFooter }; diff --git a/step2-09/exercise/src/components/TodoHeader.tsx b/step2-09/exercise/src/components/TodoHeader.tsx deleted file mode 100644 index c885b88..0000000 --- a/step2-09/exercise/src/components/TodoHeader.tsx +++ /dev/null @@ -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 { - constructor(props: TodoHeaderProps) { - super(props); - this.state = { labelInput: undefined }; - } - - render() { - return ( - - - todos - step2-09 exercise - - - - - - - Add - - - - - - - - - ); - } - - private onAdd = () => { - this.props.addTodo(this.state.labelInput); - this.setState({ labelInput: undefined }); - }; - - private onChange = (evt: React.FormEvent, newValue: string) => { - this.setState({ labelInput: newValue }); - }; - - private onFilter = (item: PivotItem) => { - this.props.setFilter(item.props.headerText as FilterTypes); - }; -} - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return { - addTodo: (label: string) => dispatch(actionsWithService.addTodo(label)), - setFilter: (filter: FilterTypes) => dispatch(actions.setFilter(filter)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoHeader); - -export { component as TodoHeader }; diff --git a/step2-09/exercise/src/components/TodoList.tsx b/step2-09/exercise/src/components/TodoList.tsx deleted file mode 100644 index 2f33d89..0000000 --- a/step2-09/exercise/src/components/TodoList.tsx +++ /dev/null @@ -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 ( - - {filteredTodos.map(id => ( - - ))} - - ); -}; - -function mapStateToProps(state: Store) { - return { ...state }; -} - -function mapDispatchToProps(dispatch: any) { - return {}; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoList); - -export { component as TodoList }; diff --git a/step2-09/exercise/src/components/TodoListItem.tsx b/step2-09/exercise/src/components/TodoListItem.tsx deleted file mode 100644 index a2f8a7b..0000000 --- a/step2-09/exercise/src/components/TodoListItem.tsx +++ /dev/null @@ -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 { - render() { - const { todos, id, complete, remove } = this.props; - const item = todos[id]; - - return ( - - complete(id)} /> -
    - remove(id)} /> -
    -
    - ); - } -} - -function mapStateToProps({ todos }: Store) { - return { - todos - }; -} - -function mapDispatchToProps(dispatch: any) { - return { - remove: (id: string) => dispatch(actionsWithService.remove(id)), - complete: (id: string) => dispatch(actionsWithService.complete(id)) - }; -} - -const component = connect( - mapStateToProps, - mapDispatchToProps -)(TodoListItem); - -export { component as TodoListItem }; diff --git a/step2-09/exercise/src/index.tsx b/step2-09/exercise/src/index.tsx deleted file mode 100644 index a015c90..0000000 --- a/step2-09/exercise/src/index.tsx +++ /dev/null @@ -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( - - - , - document.getElementById('app') - ); -})(); diff --git a/step2-09/exercise/src/reducers/index.ts b/step2-09/exercise/src/reducers/index.ts deleted file mode 100644 index 243e864..0000000 --- a/step2-09/exercise/src/reducers/index.ts +++ /dev/null @@ -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( - {}, - { - addTodo, - remove, - clear, - complete - } -); - -const filterReducer = createReducer('all', { - setFilter -}); - -export const reducer = combineReducers({ - todos: todoReducer, - filter: filterReducer -}); diff --git a/step2-09/exercise/src/reducers/pureFunctions.spec.ts b/step2-09/exercise/src/reducers/pureFunctions.spec.ts deleted file mode 100644 index 7df158a..0000000 --- a/step2-09/exercise/src/reducers/pureFunctions.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { addTodo, complete } from './pureFunctions'; -import { Store } from '../store'; - -describe('TodoApp reducers', () => { - it('can add an item', () => { - const state = {}; - - 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 = {}; - addTodo(state, { id: '0', label: 'item1' }); - complete(state, { id: '0' }); - expect(state['0'].completed).toBeTruthy(); - }); -}); diff --git a/step2-09/exercise/src/reducers/pureFunctions.ts b/step2-09/exercise/src/reducers/pureFunctions.ts deleted file mode 100644 index fa9db70..0000000 --- a/step2-09/exercise/src/reducers/pureFunctions.ts +++ /dev/null @@ -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; -} diff --git a/step2-09/exercise/src/service/index.ts b/step2-09/exercise/src/service/index.ts deleted file mode 100644 index 4c46320..0000000 --- a/step2-09/exercise/src/service/index.ts +++ /dev/null @@ -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(); -} diff --git a/step2-09/exercise/src/store/index.ts b/step2-09/exercise/src/store/index.ts deleted file mode 100644 index 221b5f4..0000000 --- a/step2-09/exercise/src/store/index.ts +++ /dev/null @@ -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; -} From f459ad5823bf380d982dc18cae84618f307576ad Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 2 Mar 2019 22:50:22 -0800 Subject: [PATCH 08/17] fixing up the demo of 2.6 --- step2-06/demo/README.md | 56 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/step2-06/demo/README.md b/step2-06/demo/README.md index 20aa091..cf88e4d 100644 --- a/step2-06/demo/README.md +++ b/step2-06/demo/README.md @@ -2,13 +2,61 @@ [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) -In this step, we will continue with Redux. Step 2.5 code can be used with any other web frameworks. This step, we will hook up the Redux store with React components. +In this step, we will continue with Redux. Step 2.5 code can be used with any other web frameworks. This step, we will hook up the Redux store with React components. There are as many ways to bind Redux to React as number of stars in the sky. Being a tad bit opinionated in this bootcamp, we picked `react-redux-hooks` for its ease of use and the fact that it is another project from Facebook. You might want to investigate other packages, such as the [`react-redux`](https://react-redux.js.org/) project. -We will demonstrate how to: +We will demonstrate how to use `react-redux-hooks` to pass down the Redux store to the views: -1. Bind Redux store to Class Components -2. Bind Redux store to Functional Components +1. Provide the Store Context +2. Bind Redux store to Class Components +3. Bind Redux store to Functional Components + +## Provide the Store Context + +Class Components will access the Redux store via the `StoreContext` from `react-redux-hooks`. In Step 2.4, you saw how the context is hooked up. So, instead of providing our own context for Redux store, we just take one that is already been created. We need to first hook up the `` component just like in Step 2.4. + +```ts +const store = createStore(reducer, {}, composeWithDevTools()); + +ReactDOM.render( + + + , + document.getElementById('app') +); +``` ## Bind Redux store to Class Components +Any class component needs access to the Redux store, we would set the `contextType` property of that component: + +```ts +class TodoListItem extends React.Component { + render() { + const { todos } = this.context.getState(); + const dispatch = this.context.dispatch; + + return (...); + } +} + +TodoListItem.contextType = StoreContext; +``` + +The `dispatch()` function as well as the state tree can be retrieved this way. `getState()` is returned so you can retrieve the current snapshot of the state tree. This simply uses the standard React _context_ API where the value is the store itself. + ## Bind Redux store to Functional Components + +Inside a functional component, we use some of the API provided by `react-redux-hooks` itself. To get specific or all of the state tree, we use the `useMappedState` hook: + +```ts +const { todos } = useMappedState(state => { + todos: state.todos; +}); +``` + +To dispatch actions, we need to retrieve this `dispatch()` function much the same way as the previous hook. We call `useDispatch()` hook: + +```ts +const dispatch = useDispatch(); +dispatch(action.addTodo('hello')); +``` From 8446b010c92b70eca2711a699c5b1dc81eea9cf5 Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 2 Mar 2019 22:56:59 -0800 Subject: [PATCH 09/17] adding step 2.4 and 2.5 exercises --- step2-04/exercise/README.md | 109 ++++++++++++++++++ step2-04/exercise/index.html | 11 ++ step2-04/exercise/src/TodoContext.ts | 4 + step2-04/exercise/src/components/TodoApp.tsx | 99 ++++++++++++++++ .../exercise/src/components/TodoFooter.tsx | 17 +++ .../exercise/src/components/TodoHeader.tsx | 64 ++++++++++ step2-04/exercise/src/components/TodoList.tsx | 21 ++++ .../exercise/src/components/TodoListItem.tsx | 76 ++++++++++++ step2-04/exercise/src/index.tsx | 10 ++ step2-04/exercise/src/store/index.ts | 14 +++ step2-05/exercise/README.md | 10 +- step2-05/exercise/src/index.tsx | 8 +- step2-05/exercise/src/reducers/index.ts | 12 +- 13 files changed, 440 insertions(+), 15 deletions(-) create mode 100644 step2-04/exercise/README.md create mode 100644 step2-04/exercise/index.html create mode 100644 step2-04/exercise/src/TodoContext.ts create mode 100644 step2-04/exercise/src/components/TodoApp.tsx create mode 100644 step2-04/exercise/src/components/TodoFooter.tsx create mode 100644 step2-04/exercise/src/components/TodoHeader.tsx create mode 100644 step2-04/exercise/src/components/TodoList.tsx create mode 100644 step2-04/exercise/src/components/TodoListItem.tsx create mode 100644 step2-04/exercise/src/index.tsx create mode 100644 step2-04/exercise/src/store/index.ts diff --git a/step2-04/exercise/README.md b/step2-04/exercise/README.md new file mode 100644 index 0000000..7f7360f --- /dev/null +++ b/step2-04/exercise/README.md @@ -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: + +![](../../assets/todo-components.png) + +## 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, `` 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 + +``` + +## 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 `` 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 ( + +
    + + + +
    +
    + ); + } +} +``` + +### Consume _context_ from a Class Component + +Inside the children components, like the `` 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
    Filter is {this.context.filter}
    ; + } +} + +// 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 ( +
    + +
    + ); +}; +``` diff --git a/step2-04/exercise/index.html b/step2-04/exercise/index.html new file mode 100644 index 0000000..ee7d10d --- /dev/null +++ b/step2-04/exercise/index.html @@ -0,0 +1,11 @@ + + + + + + +
    +
    + + + diff --git a/step2-04/exercise/src/TodoContext.ts b/step2-04/exercise/src/TodoContext.ts new file mode 100644 index 0000000..9ec0028 --- /dev/null +++ b/step2-04/exercise/src/TodoContext.ts @@ -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); diff --git a/step2-04/exercise/src/components/TodoApp.tsx b/step2-04/exercise/src/components/TodoApp.tsx new file mode 100644 index 0000000..971b527 --- /dev/null +++ b/step2-04/exercise/src/components/TodoApp.tsx @@ -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 { + constructor(props) { + super(props); + this.state = { + todos: {}, + filter: 'all' + }; + } + render() { + return ( + + + + + + + + + + ); + } + + 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 + }); + }; +} diff --git a/step2-04/exercise/src/components/TodoFooter.tsx b/step2-04/exercise/src/components/TodoFooter.tsx new file mode 100644 index 0000000..80534f9 --- /dev/null +++ b/step2-04/exercise/src/components/TodoFooter.tsx @@ -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 ( + + + {itemCount} item{itemCount === 1 ? '' : 's'} left + + context.clear()}>Clear Completed + + ); +}; diff --git a/step2-04/exercise/src/components/TodoHeader.tsx b/step2-04/exercise/src/components/TodoHeader.tsx new file mode 100644 index 0000000..f39d416 --- /dev/null +++ b/step2-04/exercise/src/components/TodoHeader.tsx @@ -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 ( + + + todos + + + + + ({ + ...(props.focused && { + field: { + backgroundColor: '#c7e0f4' + } + }) + })} + /> + + Add + + + + + + + + + ); + } + + private onAdd = () => { + this.context.addTodo(this.state.labelInput); + this.setState({ labelInput: undefined }); + }; + + private onChange = (evt: React.FormEvent, newValue: string) => { + this.setState({ labelInput: newValue }); + }; + + private onFilter = (item: PivotItem) => { + this.context.setFilter(item.props.headerText as FilterTypes); + }; +} + +TodoHeader.contextType = TodoContext; diff --git a/step2-04/exercise/src/components/TodoList.tsx b/step2-04/exercise/src/components/TodoList.tsx new file mode 100644 index 0000000..1033d8e --- /dev/null +++ b/step2-04/exercise/src/components/TodoList.tsx @@ -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 ( + + {filteredTodos.map(id => ( + + ))} + + ); +}; diff --git a/step2-04/exercise/src/components/TodoListItem.tsx b/step2-04/exercise/src/components/TodoListItem.tsx new file mode 100644 index 0000000..fa89047 --- /dev/null +++ b/step2-04/exercise/src/components/TodoListItem.tsx @@ -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 { + 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 ( + + {!this.state.editing && ( + <> + complete(id)} /> +
    + + remove(id)} /> +
    + + )} + + {this.state.editing && ( + + + + + + Save + + + )} +
    + ); + } + + 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, newValue: string) => { + this.setState({ editLabel: newValue }); + }; +} + +TodoListItem.contextType = TodoContext; diff --git a/step2-04/exercise/src/index.tsx b/step2-04/exercise/src/index.tsx new file mode 100644 index 0000000..2587243 --- /dev/null +++ b/step2-04/exercise/src/index.tsx @@ -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(, document.getElementById('app')); diff --git a/step2-04/exercise/src/store/index.ts b/step2-04/exercise/src/store/index.ts new file mode 100644 index 0000000..221b5f4 --- /dev/null +++ b/step2-04/exercise/src/store/index.ts @@ -0,0 +1,14 @@ +export type FilterTypes = 'all' | 'active' | 'completed'; + +export interface TodoItem { + label: string; + completed: boolean; +} + +export interface Store { + todos: { + [id: string]: TodoItem; + }; + + filter: FilterTypes; +} diff --git a/step2-05/exercise/README.md b/step2-05/exercise/README.md index 1cac395..630fa61 100644 --- a/step2-05/exercise/README.md +++ b/step2-05/exercise/README.md @@ -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" diff --git a/step2-05/exercise/src/index.tsx b/step2-05/exercise/src/index.tsx index cca1020..5760af8 100644 --- a/step2-05/exercise/src/index.tsx +++ b/step2-05/exercise/src/index.tsx @@ -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()); diff --git a/step2-05/exercise/src/reducers/index.ts b/step2-05/exercise/src/reducers/index.ts index 3fac61f..eb294ac 100644 --- a/step2-05/exercise/src/reducers/index.ts +++ b/step2-05/exercise/src/reducers/index.ts @@ -6,7 +6,7 @@ export const todosReducer = createReducer( {}, { 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( }, 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( }); }, + complete(state, action) { + // TODO: implement this reducer + }, + edit(state, action) { - state[action.id].label = action.label; + // TODO: implement this reducer } } ); From 46b89ff90b78df9add97c48efa2b8afd44f5a6b0 Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 2 Mar 2019 23:07:25 -0800 Subject: [PATCH 10/17] fixing up step 2-4 exercise --- step2-04/exercise/README.md | 106 ++---------------- step2-04/exercise/src/components/TodoApp.tsx | 15 +-- .../exercise/src/components/TodoFooter.tsx | 10 +- .../exercise/src/components/TodoHeader.tsx | 8 +- 4 files changed, 30 insertions(+), 109 deletions(-) diff --git a/step2-04/exercise/README.md b/step2-04/exercise/README.md index 7f7360f..507edbf 100644 --- a/step2-04/exercise/README.md +++ b/step2-04/exercise/README.md @@ -2,108 +2,22 @@ [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) -In this step, we describe some problems we encounter when creating a more complex application. +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 4 to see results. -We will solve these problems with the React Context API. The Context API consists of: +## TodoContext.Provider Component -1. Provider component -2. Consuming context from a Class Component -3. Consuming context from a Functional Component +1. Open `exercise/src/components/TodoApp.tsx` ---- +2. Uncomment the missing functions inside the value prop -React represents a single component like this: +## TodoFooter, Context inside Functional Component -``` -(props) => view; -``` +1. Open `exercise/src/components/TodoFooter.tsx` -In a real application, these functions are composed. It looks more like this: +2. Replace the two constants by using useContext(TodoContext) -![](../../assets/todo-components.png) +## TodoHeader, Context inside Class Component -## Problems in a Complex Application +1. Open `exercise/src/components/TodoHeader.tsx` -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, `` 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 - -``` - -## 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 `` 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 ( - -
    - - - -
    -
    - ); - } -} -``` - -### Consume _context_ from a Class Component - -Inside the children components, like the `` 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
    Filter is {this.context.filter}
    ; - } -} - -// 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 ( -
    - -
    - ); -}; -``` +2. Replace the couple of TODO diff --git a/step2-04/exercise/src/components/TodoApp.tsx b/step2-04/exercise/src/components/TodoApp.tsx index 971b527..ba9fc1a 100644 --- a/step2-04/exercise/src/components/TodoApp.tsx +++ b/step2-04/exercise/src/components/TodoApp.tsx @@ -20,13 +20,14 @@ export class TodoApp extends React.Component { return ( diff --git a/step2-04/exercise/src/components/TodoFooter.tsx b/step2-04/exercise/src/components/TodoFooter.tsx index 80534f9..caecff6 100644 --- a/step2-04/exercise/src/components/TodoFooter.tsx +++ b/step2-04/exercise/src/components/TodoFooter.tsx @@ -3,15 +3,19 @@ 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; + // TODO: replace the following with a useContext(TodoContext) calls + const todos = {}; + const clear = () => {}; + // - end of exercise for this file - + + const itemCount = Object.keys(todos).filter(id => !todos[id].completed).length; return ( {itemCount} item{itemCount === 1 ? '' : 's'} left - context.clear()}>Clear Completed + clear()}>Clear Completed ); }; diff --git a/step2-04/exercise/src/components/TodoHeader.tsx b/step2-04/exercise/src/components/TodoHeader.tsx index f39d416..809187e 100644 --- a/step2-04/exercise/src/components/TodoHeader.tsx +++ b/step2-04/exercise/src/components/TodoHeader.tsx @@ -48,7 +48,8 @@ export class TodoHeader extends React.Component<{}, TodoHeaderState> { } private onAdd = () => { - this.context.addTodo(this.state.labelInput); + // TODO: insert a this.context.addTodo call + // HINT: this.context.addTodo(this.state.labelInput); this.setState({ labelInput: undefined }); }; @@ -57,8 +58,9 @@ export class TodoHeader extends React.Component<{}, TodoHeaderState> { }; private onFilter = (item: PivotItem) => { - this.context.setFilter(item.props.headerText as FilterTypes); + // TODO: insert a this.context.setFilter call + // HINT: this.context.setFilter(item.props.headerText as FilterTypes); }; } -TodoHeader.contextType = TodoContext; +// TODO: TodoHeader.contextType = TodoContext; From 5b14d792f8fd428029acb729779e10bcd86b45ba Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 2 Mar 2019 23:22:09 -0800 Subject: [PATCH 11/17] Added exercises --- archived/jest/demo/README.md | 128 ------------------ archived/jest/demo/index.html | 15 -- archived/jest/demo/src/TestMe.spec.tsx | 11 -- archived/jest/demo/src/TestMe.tsx | 37 ----- archived/jest/demo/src/index.spec.tsx | 7 - archived/jest/demo/src/index.ts | 19 --- archived/jest/demo/src/multiply.ts | 3 - archived/jest/exercise/README.md | 17 --- archived/jest/exercise/index.html | 15 -- archived/jest/exercise/src/TestMe.spec.tsx | 13 -- archived/jest/exercise/src/TestMe.tsx | 37 ----- archived/jest/exercise/src/index.ts | 2 - archived/jest/exercise/src/stack.spec.ts | 18 --- archived/jest/exercise/src/stack.ts | 27 ---- step2-04/exercise/README.md | 6 +- step2-06/exercise/README.md | 24 ++-- step2-06/exercise/src/actions/index.ts | 7 +- step2-06/exercise/src/components/TodoApp.tsx | 17 +++ .../exercise/src/components/TodoFooter.tsx | 24 ++++ .../exercise/src/components/TodoHeader.tsx | 67 +++++++++ step2-06/exercise/src/components/TodoList.tsx | 19 +++ .../exercise/src/components/TodoListItem.tsx | 78 +++++++++++ step2-06/exercise/src/index.tsx | 21 +-- step2-06/exercise/src/reducers/index.ts | 54 +++++--- .../src/reducers/pureFunctions.spec.ts | 29 ---- .../exercise/src/reducers/pureFunctions.ts | 35 ----- .../exercise/src/reducers/reducer.spec.ts | 19 --- 27 files changed, 271 insertions(+), 478 deletions(-) delete mode 100644 archived/jest/demo/README.md delete mode 100644 archived/jest/demo/index.html delete mode 100644 archived/jest/demo/src/TestMe.spec.tsx delete mode 100644 archived/jest/demo/src/TestMe.tsx delete mode 100644 archived/jest/demo/src/index.spec.tsx delete mode 100644 archived/jest/demo/src/index.ts delete mode 100644 archived/jest/demo/src/multiply.ts delete mode 100644 archived/jest/exercise/README.md delete mode 100644 archived/jest/exercise/index.html delete mode 100644 archived/jest/exercise/src/TestMe.spec.tsx delete mode 100644 archived/jest/exercise/src/TestMe.tsx delete mode 100644 archived/jest/exercise/src/index.ts delete mode 100644 archived/jest/exercise/src/stack.spec.ts delete mode 100644 archived/jest/exercise/src/stack.ts create mode 100644 step2-06/exercise/src/components/TodoApp.tsx create mode 100644 step2-06/exercise/src/components/TodoFooter.tsx create mode 100644 step2-06/exercise/src/components/TodoHeader.tsx create mode 100644 step2-06/exercise/src/components/TodoList.tsx create mode 100644 step2-06/exercise/src/components/TodoListItem.tsx delete mode 100644 step2-06/exercise/src/reducers/pureFunctions.spec.ts delete mode 100644 step2-06/exercise/src/reducers/pureFunctions.ts delete mode 100644 step2-06/exercise/src/reducers/reducer.spec.ts diff --git a/archived/jest/demo/README.md b/archived/jest/demo/README.md deleted file mode 100644 index 60467a9..0000000 --- a/archived/jest/demo/README.md +++ /dev/null @@ -1,128 +0,0 @@ -# Step 2.4: Testing TypeScript code with Jest (Demo) - -[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) - -[Jest](https://jestjs.io/) is a test framework made by Facebook and is very popular in the React and wider JS ecosystems. - -In this exercise, we will work on implementing simple unit tests using Jest. - -## Jest Features - -- Multi-threaded and isolated test runner -- Provides a fake browser-like environment if needed (window, document, DOM, etc) using jsdom -- Snapshots: Jest can create text-based snapshots of rendered components. These snapshots can be checked in and show API or large object changes alongside code changes in pull requests. -- Code coverage is integrated (`--coverage`) -- Very clear error messages showing where a test failure occurred - -## How to use Jest - -- Using `create-react-app` or other project generators, Jest should already be pre-configured. Running `npm test` usually will trigger it! -- A `jest.config.js` file is used for configuration -- `jsdom` might not have enough API from real browsers, for those cases, polyfills are required. Place these inside `jest.setup.js` and hook up the setup file in `jest.config.js` -- in order to use `enzyme` library to test React Components, more config bits are needed inside `jest.setup.js` - -## What does a test look like? - -```ts -// describe(), it() and expect() are globally exported, so they don't need to be imported when jest runs these tests -describe('Something to be tested', () => { - it('should describe the behavior', () => { - expect(true).toBe(true); - }); -}); -``` - -## Testing React components using Enzyme - -[Enzyme](https://airbnb.io/enzyme/) is made by Airbnb and provides utilities to help test React components. - -In a real app using ReactDOM, the top-level component will be rendered on the page using `ReactDOM.render()`. Enzyme provides a lighter-weight `mount()` function which is usually adequate for testing purposes. - -`mount()` returns a wrapper that can be inspected and provides functionality like `find()`, simulating clicks, etc. - -The following code demonstrates how Enzyme can be used to help test React components. - -```jsx -import React from 'react'; -import { mount } from 'enzyme'; -import { TestMe } from './TestMe'; - -describe('TestMe Component', () => { - it('should have a non-clickable component when the original InnerMe is clicked', () => { - const wrapper = mount(); - wrapper.find('#innerMe').simulate('click'); - expect(wrapper.find('#innerMe').text()).toBe('Clicked'); - }); -}); - -describe('Foo Component Tests', () => { - it('allows us to set props', () => { - const wrapper = mount(); - expect(wrapper.props().bar).toBe('baz'); - wrapper.setProps({ bar: 'foo' }); - expect(wrapper.props().bar).toBe('foo'); - - wrapper.find('button').simulate('click'); - }); -}); -``` - -## Advanced topics - -### Mocking - -Mocking functions is a large part of what makes Jest a powerful testing library. Jest actually intercepts the module loading process in Node.js, allowing it to mock entire modules if needed. - -There are many ways to mock, as you'd imagine in a language as flexible as JS. We only look at the simplest case, but there's a lot of depth here. - -To mock a function: - -```ts -it('some test function', () => { - const mockCallback = jest.fn(x => 42 + x); - mockCallback(1); - mockCallback(2); - expect(mockCallback).toHaveBeenCalledTimes(2); -}); -``` - -Read more about jest mocking [here](https://jestjs.io/docs/en/mock-functions.html). - -### Async Testing - -For testing async scenarios, the test runner needs some way to know when the scenario is finished. Jest tests can handle async scenarios using callbacks, promises, or async/await. - -```ts -// Callback -it('tests callback functions', (done) => { - setTimeout(() => { - done(); - }, 1000); -}); - -// Returning a promise -it('tests promise functions', () => { - return someFunctionThatReturnsPromise()); -}); - -// Async/await (recommended) -it('tests async functions', async () => { - expect(await someFunction()).toBe(5); -}); -``` - -# Demo - -## Jest basics - -In this repo, we can start an inner loop development of tests by running `npm test` from the root of the `frontend-bootcamp` folder. - -Take a look at code inside `demo/src`: - -1. `index.ts` exports a few functions for a counter as well as a function for squaring numbers. We'll use this last function to demonstrate how mocks work. - -2. `multiply.ts` is a contrived example of a function that is exported - -3. `index.spec.ts` is the test file - -Note how tests are re-run when either test files or source files under `src` are saved. diff --git a/archived/jest/demo/index.html b/archived/jest/demo/index.html deleted file mode 100644 index 92a9499..0000000 --- a/archived/jest/demo/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - -
    -
    - For this step, we look at unit testing. Run -
    npm test
    - in the command line. -
    - - - diff --git a/archived/jest/demo/src/TestMe.spec.tsx b/archived/jest/demo/src/TestMe.spec.tsx deleted file mode 100644 index 70452b6..0000000 --- a/archived/jest/demo/src/TestMe.spec.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { TestMe } from './TestMe'; - -describe('TestMe Component', () => { - it('should have a non-clickable component when the original InnerMe is clicked', () => { - const wrapper = mount(); - wrapper.find('#innerMe').simulate('click'); - expect(wrapper.find('#innerMe').text()).toBe('Clicked'); - }); -}); diff --git a/archived/jest/demo/src/TestMe.tsx b/archived/jest/demo/src/TestMe.tsx deleted file mode 100644 index 3d68f34..0000000 --- a/archived/jest/demo/src/TestMe.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; - -export interface TestMeProps { - name: string; -} - -export interface TestMeState { - clicked: boolean; -} - -export const TestMe = (props: TestMeProps) => { - return ( -
    - -
    - ); -}; - -export class InnerMe extends React.Component { - state = { - clicked: false - }; - - onClick = () => { - this.setState({ clicked: true }); - }; - - render() { - return !this.state.clicked ? ( -
    - Hello {this.props.name}, Click Me -
    - ) : ( -
    Clicked
    - ); - } -} diff --git a/archived/jest/demo/src/index.spec.tsx b/archived/jest/demo/src/index.spec.tsx deleted file mode 100644 index 6fa5c3e..0000000 --- a/archived/jest/demo/src/index.spec.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; - -describe('index', () => { - it('placeholder', () => { - }); -}); diff --git a/archived/jest/demo/src/index.ts b/archived/jest/demo/src/index.ts deleted file mode 100644 index ecdd946..0000000 --- a/archived/jest/demo/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { multiply } from './multiply'; - -let counter = 0; - -export function getCount() { - return counter; -} - -export function increment() { - return ++counter; -} - -export function decrement() { - return --counter; -} - -export function square(x: number) { - return multiply(x, x); -} diff --git a/archived/jest/demo/src/multiply.ts b/archived/jest/demo/src/multiply.ts deleted file mode 100644 index aa528c0..0000000 --- a/archived/jest/demo/src/multiply.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function multiply(x: number, y: number) { - return x * y; -} diff --git a/archived/jest/exercise/README.md b/archived/jest/exercise/README.md deleted file mode 100644 index aed944e..0000000 --- a/archived/jest/exercise/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Step 2.4: Testing TypeScript code with Jest (Exercise) - -[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) - -Start the test runner by running `npm test` in the root of the `frontend-bootcamp` folder. - -## Basic testing - -1. Look at `exercise/src/stack.ts` for a sample implementation of a stack - -2. Follow the instructions inside `stack.spec.ts` file to complete the two tests - -## Enzyme Testing - -1. Open up `exercise/src/TestMe.spec.tsx` - -2. Fill in the test using Enzyme concepts introduced in the demo diff --git a/archived/jest/exercise/index.html b/archived/jest/exercise/index.html deleted file mode 100644 index e908cc3..0000000 --- a/archived/jest/exercise/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - -
    -
    - For this step, we look at unit testing. Run -
    npm test
    - in the command line. -
    - - - diff --git a/archived/jest/exercise/src/TestMe.spec.tsx b/archived/jest/exercise/src/TestMe.spec.tsx deleted file mode 100644 index afd5164..0000000 --- a/archived/jest/exercise/src/TestMe.spec.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { TestMe } from './TestMe'; - -describe('TestMe Component', () => { - it('should render correctly when hovered', () => { - // TODO: - // 1. mount a Component here - // 2. use enzyme wrapper's find() method to retrieve the #innerMe element - // 3. simulate a hover with "mouseover" event via the simulate() API - // 4. make assertions with expect on the text() of the #innerMe element - }); -}); diff --git a/archived/jest/exercise/src/TestMe.tsx b/archived/jest/exercise/src/TestMe.tsx deleted file mode 100644 index 8f39d8d..0000000 --- a/archived/jest/exercise/src/TestMe.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; - -export interface TestMeProps { - name: string; -} - -export interface TestMeState { - enabled: boolean; -} - -export const TestMe = (props: TestMeProps) => { - return ( -
    - -
    - ); -}; - -export class InnerMe extends React.Component { - state = { - enabled: false - }; - - onMouseOver = () => { - this.setState({ enabled: true }); - }; - - render() { - return !this.state.enabled ? ( -
    - Hello {this.props.name}, Hover Over Me -
    - ) : ( -
    Enabled
    - ); - } -} diff --git a/archived/jest/exercise/src/index.ts b/archived/jest/exercise/src/index.ts deleted file mode 100644 index 8b6ab17..0000000 --- a/archived/jest/exercise/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Stack } from './stack'; -export { TestMe } from './TestMe'; diff --git a/archived/jest/exercise/src/stack.spec.ts b/archived/jest/exercise/src/stack.spec.ts deleted file mode 100644 index 03b7d9a..0000000 --- a/archived/jest/exercise/src/stack.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: Import the stack here - -describe('Stack', () => { - it('should push item to the top of the stack', () => { - // TODO: implement test here: - // 1. Instantiate a new Stack - i.e. const stack = new Stack(); - // 2. Use stack push calls to add some items to the stack - // 3. Write assertions via the expect() API - }); - - it('should pop the item from the top of stack', () => { - // TODO: implement test here: - // 1. Instantiate a new Stack - i.e. const stack = new Stack(); - // 2. Use stack push calls to add some items to the stack - // 3. pop a few items off the stack - // 4. write assertions via the expect() API - }); -}); diff --git a/archived/jest/exercise/src/stack.ts b/archived/jest/exercise/src/stack.ts deleted file mode 100644 index 7f62d2d..0000000 --- a/archived/jest/exercise/src/stack.ts +++ /dev/null @@ -1,27 +0,0 @@ -export class Stack { - private _items: T[] = []; - - /** Add an item to the top of the stack. */ - push(item: T) { - this._items.push(item); - } - - /** Remove the top item from the stack and return it. */ - pop(): T { - if (this._items.length > 0) { - return this._items.pop(); - } - } - - /** Return the top item from the stack without removing it. */ - peek(): T { - if (this._items.length > 0) { - return this._items[this._items.length - 1]; - } - } - - /** Get the number of items in the stack/ */ - get count(): number { - return this._items.length; - } -} diff --git a/step2-04/exercise/README.md b/step2-04/exercise/README.md index 507edbf..b7118c6 100644 --- a/step2-04/exercise/README.md +++ b/step2-04/exercise/README.md @@ -20,4 +20,8 @@ If you don't already have the app running, start it by running `npm start` from 1. Open `exercise/src/components/TodoHeader.tsx` -2. Replace the couple of TODO +2. Replace the `onAdd` with a real implementation using the `this.context` object + +3. Replace the `onFilter` with a real implementation using the `this.context` object + +4. Be sure to set the `contextType` of the TodoHeader component diff --git a/step2-06/exercise/README.md b/step2-06/exercise/README.md index b8d8031..8638161 100644 --- a/step2-06/exercise/README.md +++ b/step2-06/exercise/README.md @@ -1,25 +1,17 @@ -# Step 2.6 - Redux: Dispatching actions and examining state (Exercise) +# Step 2.6: Redux: React Binding (Exercise) [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) -## Visualize state changes with Chrome extension +## Bind Redux Store with Class Component -If you still have `npm test` running from the previous step, stop it with `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 6. +1. Open `exercise/src/components/TodoHeader.tsx`. -1. Install the [Redux DevTools extension](https://github.com/zalmoxisus/redux-devtools-extension) - - [Chrome](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) - - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/reduxdevtools/) +2. Just like the 2.4 exercise, implement `onAdd` and `onFilter` using `this.context.dispatch()` calls to dispatch actions. -2. Hit F12 (`cmd+option+I` on Mac) and open the inspector panel entitled **Redux** +## Bind Redux Store with Functional Component -3. Modify `exercise/src/index.tsx` to dispatch actions (you're not limited to adding todos; you can also remove and clear) +1. Open `exercise/src/components/TodoFooter.tsx`. -4. Explore the actions' effects using the extension +2. Follow the instructions in the file to replace the `todos` const using the `useMappedState()` hook. -## Playing with dispatching actions inside tests - -Stop the app using `ctrl+C` and start the tests by running `npm test`. - -1. Open `exercise/src/reducers/reducer.spec.ts` - -2. Follow the instructions to fill out the reducer tests +3. Retrieve the dispatch function with `useDispatch()` hook. diff --git a/step2-06/exercise/src/actions/index.ts b/step2-06/exercise/src/actions/index.ts index d75c2d9..a483a82 100644 --- a/step2-06/exercise/src/actions/index.ts +++ b/step2-06/exercise/src/actions/index.ts @@ -1,5 +1,10 @@ import uuid from 'uuid/v4'; export const actions = { - addTodo: (label: string) => ({ type: 'addTodo', id: uuid(), label }) + 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 }), + edit: (id: string, label: string) => ({ type: 'edit', id, label }) }; diff --git a/step2-06/exercise/src/components/TodoApp.tsx b/step2-06/exercise/src/components/TodoApp.tsx new file mode 100644 index 0000000..4425e1d --- /dev/null +++ b/step2-06/exercise/src/components/TodoApp.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Stack } from 'office-ui-fabric-react'; +import { TodoFooter } from './TodoFooter'; +import { TodoHeader } from './TodoHeader'; +import { TodoList } from './TodoList'; + +export const TodoApp = () => { + return ( + + + + + + + + ); +}; diff --git a/step2-06/exercise/src/components/TodoFooter.tsx b/step2-06/exercise/src/components/TodoFooter.tsx new file mode 100644 index 0000000..fc5307e --- /dev/null +++ b/step2-06/exercise/src/components/TodoFooter.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { DefaultButton, Stack, Text } from 'office-ui-fabric-react'; +import { actions } from '../actions'; +import { useMappedState, useDispatch } from 'redux-react-hook'; + +export const TodoFooter = () => { + // TODO: make use of useMappedState(state => state) and the useDispatch functions to get + // the Redux store and dispatching actions + // HINT: const { todos } = useMappedState(...); + // HINT: useDispatch() here too. + const todos = {}; + const dispatch = (...args: any[]) => {}; + + const itemCount = Object.keys(todos).filter(id => !todos[id].completed).length; + + return ( + + + {itemCount} item{itemCount === 1 ? '' : 's'} left + + dispatch(actions.clear())}>Clear Completed + + ); +}; diff --git a/step2-06/exercise/src/components/TodoHeader.tsx b/step2-06/exercise/src/components/TodoHeader.tsx new file mode 100644 index 0000000..e447dad --- /dev/null +++ b/step2-06/exercise/src/components/TodoHeader.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Stack, Text, Pivot, PivotItem, TextField, PrimaryButton } from 'office-ui-fabric-react'; +import { FilterTypes } from '../store'; +import { actions } from '../actions'; +import { StoreContext } from 'redux-react-hook'; + +interface TodoHeaderState { + labelInput: string; +} + +export class TodoHeader extends React.Component<{}, TodoHeaderState> { + constructor(props: {}) { + super(props); + this.state = { labelInput: undefined }; + } + + render() { + return ( + + + todos + + + + + ({ + ...(props.focused && { + field: { + backgroundColor: '#c7e0f4' + } + }) + })} + /> + + Add + + + + + + + + + ); + } + + private onAdd = () => { + // TODO: Fill in a dispatch call to add the todo item + // HINT: this.context.dispatch(...); + this.setState({ labelInput: undefined }); + }; + + private onChange = (evt: React.FormEvent, newValue: string) => { + this.setState({ labelInput: newValue }); + }; + + private onFilter = (item: PivotItem) => { + // TODO: Fill in the dispatch call to set the filter + // HINT: this.context.dispatch(...); + }; +} + +// TODO: set the context type of this Class to StoreContext diff --git a/step2-06/exercise/src/components/TodoList.tsx b/step2-06/exercise/src/components/TodoList.tsx new file mode 100644 index 0000000..b577baa --- /dev/null +++ b/step2-06/exercise/src/components/TodoList.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Stack } from 'office-ui-fabric-react'; +import { TodoListItem } from './TodoListItem'; +import { useMappedState } from 'redux-react-hook'; + +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 ( + + {filteredTodos.map(id => ( + + ))} + + ); +}; diff --git a/step2-06/exercise/src/components/TodoListItem.tsx b/step2-06/exercise/src/components/TodoListItem.tsx new file mode 100644 index 0000000..e105851 --- /dev/null +++ b/step2-06/exercise/src/components/TodoListItem.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react'; +import { actions } from '../actions'; +import { StoreContext } from 'redux-react-hook'; + +interface TodoListItemProps { + id: string; +} + +interface TodoListItemState { + editing: boolean; + editLabel: string; +} + +export class TodoListItem extends React.Component { + constructor(props: TodoListItemProps) { + super(props); + this.state = { editing: false, editLabel: undefined }; + } + + render() { + const { id } = this.props; + const { todos } = this.context.getState(); + const dispatch = this.context.dispatch; + + const item = todos[id]; + + return ( + + {!this.state.editing && ( + <> + dispatch(actions.complete(id))} /> +
    + + dispatch(actions.remove(id))} /> +
    + + )} + + {this.state.editing && ( + + + + + + Save + + + )} +
    + ); + } + + 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(actions.edit(this.props.id, this.state.editLabel)); + this.setState({ + editing: false, + editLabel: undefined + }); + }; + + private onChange = (evt: React.FormEvent, newValue: string) => { + this.setState({ editLabel: newValue }); + }; +} + +TodoListItem.contextType = StoreContext; diff --git a/step2-06/exercise/src/index.tsx b/step2-06/exercise/src/index.tsx index 566917b..d994e52 100644 --- a/step2-06/exercise/src/index.tsx +++ b/step2-06/exercise/src/index.tsx @@ -1,16 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; import { reducer } from './reducers'; import { createStore } from 'redux'; +import { TodoApp } from './components/TodoApp'; +import { initializeIcons } from '@uifabric/icons'; import { composeWithDevTools } from 'redux-devtools-extension'; -import { actions } from './actions'; +import { StoreContext } from 'redux-react-hook'; const store = createStore(reducer, {}, composeWithDevTools()); -console.log(store.getState()); +initializeIcons(); -// TODO: dispatch several actions and see the effects on state inside the Redux devtools - -// store.dispatch(actions.???); -// store.dispatch(actions.???); -// store.dispatch(actions.???); - -console.log(store.getState()); +ReactDOM.render( + + + , + document.getElementById('app') +); diff --git a/step2-06/exercise/src/reducers/index.ts b/step2-06/exercise/src/reducers/index.ts index 6eed3c5..757ac54 100644 --- a/step2-06/exercise/src/reducers/index.ts +++ b/step2-06/exercise/src/reducers/index.ts @@ -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( + {}, + { + 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('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 +}); diff --git a/step2-06/exercise/src/reducers/pureFunctions.spec.ts b/step2-06/exercise/src/reducers/pureFunctions.spec.ts deleted file mode 100644 index b3815cf..0000000 --- a/step2-06/exercise/src/reducers/pureFunctions.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { addTodo, complete } from './pureFunctions'; -import { Store } from '../store'; - -describe('TodoApp reducers', () => { - it('can add an item', () => { - const state = {}; - - const newState = addTodo(state, '0', 'item1'); - - const keys = Object.keys(newState); - - expect(newState).not.toBe(state); - expect(keys.length).toBe(1); - expect(newState[keys[0]].label).toBe('item1'); - expect(newState[keys[0]].completed).toBeFalsy(); - }); - - it('can complete an item', () => { - const state = {}; - - let newState = addTodo(state, '0', 'item1'); - - const key = Object.keys(newState)[0]; - - newState = complete(newState, key); - - expect(newState[key].completed).toBeTruthy(); - }); -}); diff --git a/step2-06/exercise/src/reducers/pureFunctions.ts b/step2-06/exercise/src/reducers/pureFunctions.ts deleted file mode 100644 index e1954e5..0000000 --- a/step2-06/exercise/src/reducers/pureFunctions.ts +++ /dev/null @@ -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; -} diff --git a/step2-06/exercise/src/reducers/reducer.spec.ts b/step2-06/exercise/src/reducers/reducer.spec.ts deleted file mode 100644 index 86b9272..0000000 --- a/step2-06/exercise/src/reducers/reducer.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createStore } from 'redux'; -import { reducer } from '.'; -import { actions } from '../actions'; - -describe('reducers', () => { - it('should add items', () => { - // 1. Use Redux's createStore() to create a store. Pass in a reducer along with the initial state. - // - // 2. Call store.dispatch() with some action messages to indicate the kind of - // action to perform (in this case, addTodo) - // - // 3. Assert with expect() on the resultant store.getState().todos - }); - - // Tests left for you to write: - // - remove - // - clear - // - complete -}); From 3eb97ab85a9f8c0a3b36ef0692a55c4234ddb4ad Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 2 Mar 2019 23:33:21 -0800 Subject: [PATCH 12/17] fixing a link in index --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 3a6827e..c0ed6a3 100644 --- a/index.html +++ b/index.html @@ -132,7 +132,7 @@
  • - + Redux: Service Calls
  • From 27bf77b34f92d34bcf61a9be2ade3f978f4d8ea1 Mon Sep 17 00:00:00 2001 From: Ken Date: Sun, 3 Mar 2019 09:31:59 -0800 Subject: [PATCH 13/17] swapped it back to react-redux --- package.json | 1 - step2-05/demo/README.md | 18 ++++- step2-06/demo/README.md | 81 ++++++++++--------- step2-06/demo/src/components/TodoFooter.tsx | 26 ++++-- step2-06/demo/src/components/TodoHeader.tsx | 25 ++++-- step2-06/demo/src/components/TodoList.tsx | 15 +++- step2-06/demo/src/components/TodoListItem.tsx | 19 ++++- step2-06/demo/src/index.tsx | 6 +- 8 files changed, 131 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index cda9618..6accdc7 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,6 @@ "redux": "^4.0.1", "redux-devtools-extension": "^2.13.8", "redux-starter-kit": "^0.4.3", - "redux-react-hook": "^3.2.0", "redux-thunk": "^2.3.0" } } diff --git a/step2-05/demo/README.md b/step2-05/demo/README.md index fcdf68c..bbf0501 100644 --- a/step2-05/demo/README.md +++ b/step2-05/demo/README.md @@ -5,12 +5,15 @@ In this step, we will look at solving the problems of complex application (as mentioned in Step 4) with a library called Redux. 1. Introduction to Redux -2. Creating the Redux store -3. Writing reducers -4. Dispatching actions +2. Why Use Redux? +3. Creating the Redux store +4. Writing reducers +5. Dispatching actions --- +## Introduction to Redux + As a reminder, the problem that we want to address are: 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. @@ -38,6 +41,15 @@ The [store](https://redux.js.org/basics/store) consists of a **state tree**, a * 3. **Reducers** are functions that take in the current state tree and an action, producing the next snapshot of the state tree. +## Why Use Redux? + +There are lots of alternatives available, but here are some really good reasons to go with Redux: + +1. For more complex applications, Flux pattern forces code to be written in a way that is easy to reason about +2. There maybe a need to serialize the application state to be transmitted across the wire somehow +3. Dev tooling is really amazing +4. Popularity of the framework means the ecosystem is mature at this point + # Creating the Redux store The [`createStore()`](https://redux.js.org/api/createstore) function is provided by Redux and can take in several arguments. The simplest form just takes in reducers. diff --git a/step2-06/demo/README.md b/step2-06/demo/README.md index cf88e4d..38e8dfa 100644 --- a/step2-06/demo/README.md +++ b/step2-06/demo/README.md @@ -2,61 +2,68 @@ [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) -In this step, we will continue with Redux. Step 2.5 code can be used with any other web frameworks. This step, we will hook up the Redux store with React components. There are as many ways to bind Redux to React as number of stars in the sky. Being a tad bit opinionated in this bootcamp, we picked `react-redux-hooks` for its ease of use and the fact that it is another project from Facebook. You might want to investigate other packages, such as the [`react-redux`](https://react-redux.js.org/) project. +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. -We will demonstrate how to use `react-redux-hooks` to pass down the Redux store to the views: +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. -1. Provide the Store Context -2. Bind Redux store to Class Components -3. Bind Redux store to Functional Components +In this step, we will continue with Redux. Step 2.5 code can be used with any other web frameworks. This step, we will hook up the Redux store with React components. The official way to do this is with the the [`react-redux`](https://react-redux.js.org/) library. + +We will demonstrate how to use `react-redux` to pass down the Redux store to the views: + +1. Providing the Store to the Views +2. Mapping the Redux store to props ## Provide the Store Context Class Components will access the Redux store via the `StoreContext` from `react-redux-hooks`. In Step 2.4, you saw how the context is hooked up. So, instead of providing our own context for Redux store, we just take one that is already been created. We need to first hook up the `` component just like in Step 2.4. -```ts -const store = createStore(reducer, {}, composeWithDevTools()); +```js +const store = createStore(reducers); -ReactDOM.render( - - - , - document.getElementById('app') -); +const App = () => { + return ( + +
    Hello World!
    +
    + ); +}; ``` -## Bind Redux store to Class Components +## Mapping the Redux store to props -Any class component needs access to the Redux store, we would set the `contextType` property of that component: +`react-redux` exports the [`connect()`](https://react-redux.js.org/api/connect) function that maps portions of the state tree and dispatch functions into props in the child React component. Let's look at how that is done. -```ts -class TodoListItem extends React.Component { - render() { - const { todos } = this.context.getState(); - const dispatch = this.context.dispatch; +```js +import { connect } from 'react-redux'; - return (...); +const MyComponent = props => { + return
    + {props.prop1} + +
    ; +}; + +const ConnectedComponent = connect( + state => { + prop1: state.key1, + prop2: state.key2 + }, + dispatch => { + action1: (arg) => dispatch(actions.action1(arg)), + action2: (arg) => dispatch(actions.action2(arg)), } -} - -TodoListItem.contextType = StoreContext; +)(MyComponent); ``` -The `dispatch()` function as well as the state tree can be retrieved this way. `getState()` is returned so you can retrieve the current snapshot of the state tree. This simply uses the standard React _context_ API where the value is the store itself. +So, that's a lot to digest. We'll go through these different parts: -## Bind Redux store to Functional Components +1. First, the `` is simple component that expects to have props, without any knowledge of Redux. It is just a plain React Component. -Inside a functional component, we use some of the API provided by `react-redux-hooks` itself. To get specific or all of the state tree, we use the `useMappedState` hook: +2. The `connect()` function takes in several arguments. -```ts -const { todos } = useMappedState(state => { - todos: state.todos; -}); -``` + - The first argument maps portions of the Redux _state tree_ into `` _props_ + - The second arguments maps dispatch functions into `` _props_ -To dispatch actions, we need to retrieve this `dispatch()` function much the same way as the previous hook. We call `useDispatch()` hook: +3. Finally, `connect()` actually returns a function that **decorates** a `` into `` - it is a strange syntax, so do study it more closely here. -```ts -const dispatch = useDispatch(); -dispatch(action.addTodo('hello')); -``` +> Yes, `connect()` is a function that takes in functions as arguments that returns a function that takes in a component that return a component. Try to say this fast 10 times :) diff --git a/step2-06/demo/src/components/TodoFooter.tsx b/step2-06/demo/src/components/TodoFooter.tsx index d38e454..3d4f4fb 100644 --- a/step2-06/demo/src/components/TodoFooter.tsx +++ b/step2-06/demo/src/components/TodoFooter.tsx @@ -1,11 +1,16 @@ import React from 'react'; import { DefaultButton, Stack, Text } from 'office-ui-fabric-react'; import { actions } from '../actions'; -import { useMappedState, useDispatch } from 'redux-react-hook'; +import { connect } from 'react-redux'; +import { Store } from '../store'; -export const TodoFooter = () => { - const { todos } = useMappedState(state => state); - const dispatch = useDispatch(); +interface TodoFooterProps { + todos: Store['todos']; + clear: () => void; +} + +const TodoFooter = (props: TodoFooterProps) => { + const { todos, clear } = props; const itemCount = Object.keys(todos).filter(id => !todos[id].completed).length; @@ -14,7 +19,18 @@ export const TodoFooter = () => { {itemCount} item{itemCount === 1 ? '' : 's'} left - dispatch(actions.clear())}>Clear Completed + clear()}>Clear Completed
    ); }; + +const ConnectedTodoFooter = connect( + (state: Store) => ({ + todos: state.todos + }), + dispatch => ({ + clear: () => dispatch(actions.clear()) + }) +)(TodoFooter); + +export { ConnectedTodoFooter as TodoFooter }; diff --git a/step2-06/demo/src/components/TodoHeader.tsx b/step2-06/demo/src/components/TodoHeader.tsx index a272037..c0cc75e 100644 --- a/step2-06/demo/src/components/TodoHeader.tsx +++ b/step2-06/demo/src/components/TodoHeader.tsx @@ -2,14 +2,19 @@ import React from 'react'; import { Stack, Text, Pivot, PivotItem, TextField, PrimaryButton } from 'office-ui-fabric-react'; import { FilterTypes } from '../store'; import { actions } from '../actions'; -import { StoreContext } from 'redux-react-hook'; +import { connect } from 'react-redux'; + +interface TodoHeaderProps { + addTodo: (label: string) => void; + setFilter: (filter: FilterTypes) => void; +} interface TodoHeaderState { labelInput: string; } -export class TodoHeader extends React.Component<{}, TodoHeaderState> { - constructor(props: {}) { +class TodoHeader extends React.Component { + constructor(props: TodoHeaderProps) { super(props); this.state = { labelInput: undefined }; } @@ -49,7 +54,7 @@ export class TodoHeader extends React.Component<{}, TodoHeaderState> { } private onAdd = () => { - this.context.dispatch(actions.addTodo(this.state.labelInput)); + this.props.addTodo(this.state.labelInput); this.setState({ labelInput: undefined }); }; @@ -58,8 +63,16 @@ export class TodoHeader extends React.Component<{}, TodoHeaderState> { }; private onFilter = (item: PivotItem) => { - this.context.dispatch(actions.setFilter(item.props.headerText as FilterTypes)); + this.props.setFilter(item.props.headerText as FilterTypes); }; } -TodoHeader.contextType = StoreContext; +const ConnectedTodoHeader = connect( + state => {}, + dispatch => ({ + addTodo: label => dispatch(actions.addTodo(label)), + setFilter: filter => dispatch(actions.setFilter(filter)) + }) +)(TodoHeader); + +export { ConnectedTodoHeader as TodoHeader }; diff --git a/step2-06/demo/src/components/TodoList.tsx b/step2-06/demo/src/components/TodoList.tsx index b577baa..02cad3f 100644 --- a/step2-06/demo/src/components/TodoList.tsx +++ b/step2-06/demo/src/components/TodoList.tsx @@ -1,10 +1,16 @@ import React from 'react'; import { Stack } from 'office-ui-fabric-react'; import { TodoListItem } from './TodoListItem'; -import { useMappedState } from 'redux-react-hook'; +import { connect } from 'react-redux'; +import { Store } from '../store'; -export const TodoList = () => { - const { filter, todos } = useMappedState(state => state); +interface TodoListProps { + todos: Store['todos']; + filter: Store['filter']; +} + +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); }); @@ -17,3 +23,6 @@ export const TodoList = () => { ); }; + +const ConnectedTodoList = connect((state: Store) => ({ ...state }))(TodoList); +export { ConnectedTodoList as TodoList }; diff --git a/step2-06/demo/src/components/TodoListItem.tsx b/step2-06/demo/src/components/TodoListItem.tsx index e105851..cd6cc74 100644 --- a/step2-06/demo/src/components/TodoListItem.tsx +++ b/step2-06/demo/src/components/TodoListItem.tsx @@ -2,9 +2,15 @@ import React from 'react'; import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react'; import { actions } from '../actions'; import { StoreContext } from 'redux-react-hook'; +import { Store } from '../store'; +import { connect } from 'react-redux'; interface TodoListItemProps { id: string; + todos: Store['todos']; + complete: (id: string) => void; + remove: (id: string) => void; + edit: (id: string, label: string) => void; } interface TodoListItemState { @@ -12,7 +18,7 @@ interface TodoListItemState { editLabel: string; } -export class TodoListItem extends React.Component { +class TodoListItem extends React.Component { constructor(props: TodoListItemProps) { super(props); this.state = { editing: false, editLabel: undefined }; @@ -75,4 +81,13 @@ export class TodoListItem extends React.Component ({ todos: state.todos }), + dispatch => ({ + complete: label => dispatch(actions.addTodo(label)), + remove: label => dispatch(actions.addTodo(label)), + edit: filter => dispatch(actions.setFilter(filter)) + }) +)(TodoListItem); + +export { ConnectedTodoListItem as TodoListItem }; diff --git a/step2-06/demo/src/index.tsx b/step2-06/demo/src/index.tsx index d994e52..ba2b58b 100644 --- a/step2-06/demo/src/index.tsx +++ b/step2-06/demo/src/index.tsx @@ -5,15 +5,15 @@ import { createStore } from 'redux'; import { TodoApp } from './components/TodoApp'; import { initializeIcons } from '@uifabric/icons'; import { composeWithDevTools } from 'redux-devtools-extension'; -import { StoreContext } from 'redux-react-hook'; +import { Provider } from 'react-redux'; const store = createStore(reducer, {}, composeWithDevTools()); initializeIcons(); ReactDOM.render( - + - , +
    , document.getElementById('app') ); From 21a61f245068a85591c1fe807c2ea75d8fac3447 Mon Sep 17 00:00:00 2001 From: Ken Date: Sun, 3 Mar 2019 09:49:43 -0800 Subject: [PATCH 14/17] fixing up react-redux for all demo examples --- index.html | 2 +- package-lock.json | 5 --- step2-05/exercise/README.md | 2 +- step2-06/demo/src/components/TodoListItem.tsx | 3 +- step2-06/exercise/README.md | 18 +++++---- .../exercise/src/components/TodoFooter.tsx | 37 ++++++++++++++----- .../exercise/src/components/TodoHeader.tsx | 35 ++++++++++++++---- step2-06/exercise/src/components/TodoList.tsx | 15 ++++++-- .../exercise/src/components/TodoListItem.tsx | 22 +++++++++-- step2-06/exercise/src/index.tsx | 10 ++--- step2-07/demo/src/components/TodoFooter.tsx | 26 ++++++++++--- step2-07/demo/src/components/TodoHeader.tsx | 27 ++++++++++---- step2-07/demo/src/components/TodoList.tsx | 15 ++++++-- step2-07/demo/src/components/TodoListItem.tsx | 28 ++++++++++---- step2-07/demo/src/index.tsx | 6 +-- 15 files changed, 179 insertions(+), 72 deletions(-) diff --git a/index.html b/index.html index c0ed6a3..9d4da2b 100644 --- a/index.html +++ b/index.html @@ -132,7 +132,7 @@
  • - + Redux: Service Calls
  • diff --git a/package-lock.json b/package-lock.json index f73efe1..5e52d08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8527,11 +8527,6 @@ "json-stringify-safe": "^5.0.1" } }, - "redux-react-hook": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/redux-react-hook/-/redux-react-hook-3.2.0.tgz", - "integrity": "sha512-GibqTO/Cgl2nRuhw2wacyfd2Nds8pYAPU/eYuTqXZ9v01CT7s7LSwDfPdtQua7TBqE8XNjrwKZqfDyEtfXt7vQ==" - }, "redux-starter-kit": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/redux-starter-kit/-/redux-starter-kit-0.4.3.tgz", diff --git a/step2-05/exercise/README.md b/step2-05/exercise/README.md index 630fa61..52c9eb2 100644 --- a/step2-05/exercise/README.md +++ b/step2-05/exercise/README.md @@ -2,7 +2,7 @@ [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) -If you still have the app running from a previous step, stop it with `ctrl+c`. Start the tests instead by running `npm test` from the root of the `frontend-bootcamp` folder. +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 5 to see results. 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. diff --git a/step2-06/demo/src/components/TodoListItem.tsx b/step2-06/demo/src/components/TodoListItem.tsx index cd6cc74..2ac0ec7 100644 --- a/step2-06/demo/src/components/TodoListItem.tsx +++ b/step2-06/demo/src/components/TodoListItem.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react'; import { actions } from '../actions'; -import { StoreContext } from 'redux-react-hook'; import { Store } from '../store'; import { connect } from 'react-redux'; @@ -69,7 +68,7 @@ class TodoListItem extends React.Component }; private onDoneEdit = () => { - this.context.dispatch(actions.edit(this.props.id, this.state.editLabel)); + this.props.edit(this.props.id, this.state.editLabel); this.setState({ editing: false, editLabel: undefined diff --git a/step2-06/exercise/README.md b/step2-06/exercise/README.md index 8638161..0cb2da9 100644 --- a/step2-06/exercise/README.md +++ b/step2-06/exercise/README.md @@ -2,16 +2,20 @@ [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) -## Bind Redux Store with Class Component +If you haven't arStart the app by running `npm start` from the root of the `frontend-bootcamp` folder. Click the "exercise" link under day 2 step 6 to see results. -1. Open `exercise/src/components/TodoHeader.tsx`. +At the beginning of this exercise, the "Add" and "Clear Completed" buttons do not work. We'll be fixing that in this step! -2. Just like the 2.4 exercise, implement `onAdd` and `onFilter` using `this.context.dispatch()` calls to dispatch actions. +1. Open `exercise/src/index.tsx` and wrap `` with `` as instructed in the comment -## Bind Redux Store with Functional Component +2. Open `exercise/src/components/TodoFooter.tsx` and erase the "nullable" type modifier (i.e. the ?) in the interface definition of `TodoFooterProps` -1. Open `exercise/src/components/TodoFooter.tsx`. +3. Uncomment the bottom bits of code and fill in `connect()` arguments - feel free to use `TodoListItem.tsx` as a guide -2. Follow the instructions in the file to replace the `todos` const using the `useMappedState()` hook. +4. Repeat steps 2, 3 for the `TodoHeader.tsx` file -3. Retrieve the dispatch function with `useDispatch()` hook. +## 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 diff --git a/step2-06/exercise/src/components/TodoFooter.tsx b/step2-06/exercise/src/components/TodoFooter.tsx index fc5307e..576a644 100644 --- a/step2-06/exercise/src/components/TodoFooter.tsx +++ b/step2-06/exercise/src/components/TodoFooter.tsx @@ -1,15 +1,18 @@ import React from 'react'; import { DefaultButton, Stack, Text } from 'office-ui-fabric-react'; import { actions } from '../actions'; -import { useMappedState, useDispatch } from 'redux-react-hook'; +import { connect } from 'react-redux'; +import { Store } from '../store'; -export const TodoFooter = () => { - // TODO: make use of useMappedState(state => state) and the useDispatch functions to get - // the Redux store and dispatching actions - // HINT: const { todos } = useMappedState(...); - // HINT: useDispatch() here too. - const todos = {}; - const dispatch = (...args: any[]) => {}; +// TODO: these ?'s after the keys of an interface makes it optional +// and can be removed when you finished connecting this component +interface TodoFooterProps { + todos?: Store['todos']; + clear?: () => void; +} + +const TodoFooter = (props: TodoFooterProps) => { + const { todos, clear } = props; const itemCount = Object.keys(todos).filter(id => !todos[id].completed).length; @@ -18,7 +21,23 @@ export const TodoFooter = () => { {itemCount} item{itemCount === 1 ? '' : 's'} left - dispatch(actions.clear())}>Clear Completed + clear()}>Clear Completed ); }; + +// TODO: write out the mapping functions for state and dispatch functions +/* + HINT: you can get started by copy pasting below code as arguments to connect() + + (state: Store) => ({ + // TODO: mapping for state + // HINT: look at what the component needed from the props interface + }), + dispatch => ({ + // TODO: mapping for dispatch actions + // HINT: look at what the component needed from the props interface + }) +*/ +const ConnectedTodoFooter = connect()(TodoFooter); +export { ConnectedTodoFooter as TodoFooter }; diff --git a/step2-06/exercise/src/components/TodoHeader.tsx b/step2-06/exercise/src/components/TodoHeader.tsx index e447dad..3a1c288 100644 --- a/step2-06/exercise/src/components/TodoHeader.tsx +++ b/step2-06/exercise/src/components/TodoHeader.tsx @@ -2,14 +2,21 @@ import React from 'react'; import { Stack, Text, Pivot, PivotItem, TextField, PrimaryButton } from 'office-ui-fabric-react'; import { FilterTypes } from '../store'; import { actions } from '../actions'; -import { StoreContext } from 'redux-react-hook'; +import { connect } from 'react-redux'; + +// TODO: these ?'s after the keys of an interface makes it optional +// and can be removed when you finished connecting this component +interface TodoHeaderProps { + addTodo?: (label: string) => void; + setFilter?: (filter: FilterTypes) => void; +} interface TodoHeaderState { labelInput: string; } -export class TodoHeader extends React.Component<{}, TodoHeaderState> { - constructor(props: {}) { +class TodoHeader extends React.Component { + constructor(props: TodoHeaderProps) { super(props); this.state = { labelInput: undefined }; } @@ -49,8 +56,7 @@ export class TodoHeader extends React.Component<{}, TodoHeaderState> { } private onAdd = () => { - // TODO: Fill in a dispatch call to add the todo item - // HINT: this.context.dispatch(...); + this.props.addTodo(this.state.labelInput); this.setState({ labelInput: undefined }); }; @@ -59,9 +65,22 @@ export class TodoHeader extends React.Component<{}, TodoHeaderState> { }; private onFilter = (item: PivotItem) => { - // TODO: Fill in the dispatch call to set the filter - // HINT: this.context.dispatch(...); + this.props.setFilter(item.props.headerText as FilterTypes); }; } -// TODO: set the context type of this Class to StoreContext +// TODO: write out the mapping functions for state and dispatch functions +/* + HINT: you can get started by copy pasting below code as arguments to connect() + + (state: Store) => ({ + // TODO: mapping for state + // HINT: look at what the component needed from the props interface + }), + dispatch => ({ + // TODO: mapping for dispatch actions + // HINT: look at what the component needed from the props interface + }) +*/ +const ConnectedTodoHeader = connect()(TodoHeader); +export { ConnectedTodoHeader as TodoHeader }; diff --git a/step2-06/exercise/src/components/TodoList.tsx b/step2-06/exercise/src/components/TodoList.tsx index b577baa..02cad3f 100644 --- a/step2-06/exercise/src/components/TodoList.tsx +++ b/step2-06/exercise/src/components/TodoList.tsx @@ -1,10 +1,16 @@ import React from 'react'; import { Stack } from 'office-ui-fabric-react'; import { TodoListItem } from './TodoListItem'; -import { useMappedState } from 'redux-react-hook'; +import { connect } from 'react-redux'; +import { Store } from '../store'; -export const TodoList = () => { - const { filter, todos } = useMappedState(state => state); +interface TodoListProps { + todos: Store['todos']; + filter: Store['filter']; +} + +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); }); @@ -17,3 +23,6 @@ export const TodoList = () => { ); }; + +const ConnectedTodoList = connect((state: Store) => ({ ...state }))(TodoList); +export { ConnectedTodoList as TodoList }; diff --git a/step2-06/exercise/src/components/TodoListItem.tsx b/step2-06/exercise/src/components/TodoListItem.tsx index e105851..2ac0ec7 100644 --- a/step2-06/exercise/src/components/TodoListItem.tsx +++ b/step2-06/exercise/src/components/TodoListItem.tsx @@ -1,10 +1,15 @@ import React from 'react'; import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react'; import { actions } from '../actions'; -import { StoreContext } from 'redux-react-hook'; +import { Store } from '../store'; +import { connect } from 'react-redux'; interface TodoListItemProps { id: string; + todos: Store['todos']; + complete: (id: string) => void; + remove: (id: string) => void; + edit: (id: string, label: string) => void; } interface TodoListItemState { @@ -12,7 +17,7 @@ interface TodoListItemState { editLabel: string; } -export class TodoListItem extends React.Component { +class TodoListItem extends React.Component { constructor(props: TodoListItemProps) { super(props); this.state = { editing: false, editLabel: undefined }; @@ -63,7 +68,7 @@ export class TodoListItem extends React.Component { - this.context.dispatch(actions.edit(this.props.id, this.state.editLabel)); + this.props.edit(this.props.id, this.state.editLabel); this.setState({ editing: false, editLabel: undefined @@ -75,4 +80,13 @@ export class TodoListItem extends React.Component ({ todos: state.todos }), + dispatch => ({ + complete: label => dispatch(actions.addTodo(label)), + remove: label => dispatch(actions.addTodo(label)), + edit: filter => dispatch(actions.setFilter(filter)) + }) +)(TodoListItem); + +export { ConnectedTodoListItem as TodoListItem }; diff --git a/step2-06/exercise/src/index.tsx b/step2-06/exercise/src/index.tsx index d994e52..c4d04eb 100644 --- a/step2-06/exercise/src/index.tsx +++ b/step2-06/exercise/src/index.tsx @@ -5,15 +5,11 @@ import { createStore } from 'redux'; import { TodoApp } from './components/TodoApp'; import { initializeIcons } from '@uifabric/icons'; import { composeWithDevTools } from 'redux-devtools-extension'; -import { StoreContext } from 'redux-react-hook'; +// TODO: import { Provider } from 'react-redux'; const store = createStore(reducer, {}, composeWithDevTools()); initializeIcons(); -ReactDOM.render( - - - , - document.getElementById('app') -); +// TODO: wrap the component with a component +ReactDOM.render(, document.getElementById('app')); diff --git a/step2-07/demo/src/components/TodoFooter.tsx b/step2-07/demo/src/components/TodoFooter.tsx index b92c239..ec5de8c 100644 --- a/step2-07/demo/src/components/TodoFooter.tsx +++ b/step2-07/demo/src/components/TodoFooter.tsx @@ -1,11 +1,16 @@ import React from 'react'; import { DefaultButton, Stack, Text } from 'office-ui-fabric-react'; import { actionsWithService } from '../actions'; -import { useMappedState, useDispatch } from 'redux-react-hook'; +import { connect } from 'react-redux'; +import { Store } from '../store'; -export const TodoFooter = () => { - const { todos } = useMappedState(state => state); - const dispatch = useDispatch(); +interface TodoFooterProps { + todos: Store['todos']; + clear: () => void; +} + +const TodoFooter = (props: TodoFooterProps) => { + const { todos, clear } = props; const itemCount = Object.keys(todos).filter(id => !todos[id].completed).length; @@ -14,7 +19,18 @@ export const TodoFooter = () => { {itemCount} item{itemCount === 1 ? '' : 's'} left - dispatch(actionsWithService.clear())}>Clear Completed + clear()}>Clear Completed ); }; + +const ConnectedTodoFooter = connect( + (state: Store) => ({ + todos: state.todos + }), + (dispatch: any) => ({ + clear: () => dispatch(actionsWithService.clear()) + }) +)(TodoFooter); + +export { ConnectedTodoFooter as TodoFooter }; diff --git a/step2-07/demo/src/components/TodoHeader.tsx b/step2-07/demo/src/components/TodoHeader.tsx index 871febc..997b4db 100644 --- a/step2-07/demo/src/components/TodoHeader.tsx +++ b/step2-07/demo/src/components/TodoHeader.tsx @@ -1,15 +1,20 @@ import React from 'react'; 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'; +import { actions, actionsWithService } from '../actions'; +import { connect } from 'react-redux'; + +interface TodoHeaderProps { + addTodo: (label: string) => void; + setFilter: (filter: FilterTypes) => void; +} interface TodoHeaderState { labelInput: string; } -export class TodoHeader extends React.Component<{}, TodoHeaderState> { - constructor(props: {}) { +class TodoHeader extends React.Component { + constructor(props: TodoHeaderProps) { super(props); this.state = { labelInput: undefined }; } @@ -49,7 +54,7 @@ export class TodoHeader extends React.Component<{}, TodoHeaderState> { } private onAdd = () => { - this.context.dispatch(actionsWithService.addTodo(this.state.labelInput)); + this.props.addTodo(this.state.labelInput); this.setState({ labelInput: undefined }); }; @@ -58,8 +63,16 @@ export class TodoHeader extends React.Component<{}, TodoHeaderState> { }; private onFilter = (item: PivotItem) => { - this.context.dispatch(actions.setFilter(item.props.headerText as FilterTypes)); + this.props.setFilter(item.props.headerText as FilterTypes); }; } -TodoHeader.contextType = StoreContext; +const ConnectedTodoHeader = connect( + state => {}, + (dispatch: any) => ({ + addTodo: label => dispatch(actionsWithService.addTodo(label)), + setFilter: filter => dispatch(actions.setFilter(filter)) + }) +)(TodoHeader); + +export { ConnectedTodoHeader as TodoHeader }; diff --git a/step2-07/demo/src/components/TodoList.tsx b/step2-07/demo/src/components/TodoList.tsx index b577baa..02cad3f 100644 --- a/step2-07/demo/src/components/TodoList.tsx +++ b/step2-07/demo/src/components/TodoList.tsx @@ -1,10 +1,16 @@ import React from 'react'; import { Stack } from 'office-ui-fabric-react'; import { TodoListItem } from './TodoListItem'; -import { useMappedState } from 'redux-react-hook'; +import { connect } from 'react-redux'; +import { Store } from '../store'; -export const TodoList = () => { - const { filter, todos } = useMappedState(state => state); +interface TodoListProps { + todos: Store['todos']; + filter: Store['filter']; +} + +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); }); @@ -17,3 +23,6 @@ export const TodoList = () => { ); }; + +const ConnectedTodoList = connect((state: Store) => ({ ...state }))(TodoList); +export { ConnectedTodoList as TodoList }; diff --git a/step2-07/demo/src/components/TodoListItem.tsx b/step2-07/demo/src/components/TodoListItem.tsx index 71d9b1e..947feaa 100644 --- a/step2-07/demo/src/components/TodoListItem.tsx +++ b/step2-07/demo/src/components/TodoListItem.tsx @@ -1,10 +1,15 @@ import React from 'react'; import { Stack, Checkbox, IconButton, TextField, DefaultButton } from 'office-ui-fabric-react'; -import { actionsWithService } from '../actions'; -import { StoreContext } from 'redux-react-hook'; +import { actions, actionsWithService } from '../actions'; +import { Store } from '../store'; +import { connect } from 'react-redux'; interface TodoListItemProps { id: string; + todos: Store['todos']; + complete: (id: string) => void; + remove: (id: string) => void; + edit: (id: string, label: string) => void; } interface TodoListItemState { @@ -12,7 +17,7 @@ interface TodoListItemState { editLabel: string; } -export class TodoListItem extends React.Component { +class TodoListItem extends React.Component { constructor(props: TodoListItemProps) { super(props); this.state = { editing: false, editLabel: undefined }; @@ -29,10 +34,10 @@ export class TodoListItem extends React.Component {!this.state.editing && ( <> - dispatch(actionsWithService.complete(id))} /> + dispatch(actions.complete(id))} />
    - dispatch(actionsWithService.remove(id))} /> + dispatch(actions.remove(id))} />
    )} @@ -63,7 +68,7 @@ export class TodoListItem extends React.Component { - this.context.dispatch(actionsWithService.edit(this.props.id, this.state.editLabel)); + this.props.edit(this.props.id, this.state.editLabel); this.setState({ editing: false, editLabel: undefined @@ -75,4 +80,13 @@ export class TodoListItem extends React.Component ({ todos: state.todos }), + (dispatch: any) => ({ + complete: label => dispatch(actionsWithService.addTodo(label)), + remove: label => dispatch(actionsWithService.addTodo(label)), + edit: filter => dispatch(actions.setFilter(filter)) + }) +)(TodoListItem); + +export { ConnectedTodoListItem as TodoListItem }; diff --git a/step2-07/demo/src/index.tsx b/step2-07/demo/src/index.tsx index eadd502..4cc9e8c 100644 --- a/step2-07/demo/src/index.tsx +++ b/step2-07/demo/src/index.tsx @@ -3,9 +3,9 @@ import ReactDOM from 'react-dom'; import { reducer } from './reducers'; import { createStore, applyMiddleware } from 'redux'; import { TodoApp } from './components/TodoApp'; +import { Provider } from 'react-redux'; 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'; @@ -22,9 +22,9 @@ import { FilterTypes } from './store'; initializeIcons(); ReactDOM.render( - + - , +
    , document.getElementById('app') ); })(); From 2da055ff1d3d368c5cf6a289f2544e33e5fa7eeb Mon Sep 17 00:00:00 2001 From: Ken Date: Sun, 3 Mar 2019 10:25:01 -0800 Subject: [PATCH 15/17] fixing up section titles and such for 2-4 --- step2-04/demo/README.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/step2-04/demo/README.md b/step2-04/demo/README.md index 95824fa..63cb37b 100644 --- a/step2-04/demo/README.md +++ b/step2-04/demo/README.md @@ -4,14 +4,17 @@ 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: +We will solve these problems with the React Context API. The Context API consists of Provider and Consumer components. Let's take a look at what is in this step: -1. Provider component -2. Consuming context from a Class Component -3. Consuming context from a Functional Component +1. The problem of complex applications +2. React Context API +3. Consuming context from a Class Component +4. Consuming context from a Functional Component --- +## The problem of complex applications + React represents a single component like this: ``` @@ -22,8 +25,6 @@ In a real application, these functions are composed. It looks more like this: ![](../../assets/todo-components.png) -## 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 @@ -46,9 +47,13 @@ All of these props are not used, except to be passed down to a child Component, ``` -## Context API +## React 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 `` component to wrap a part of the component tree that should be handed the _context_. +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. In simpler terms, it solves the props drilling issue above! + +React context is created by calling `createContext()` with some initial data. Use the `` component to wrap a part of the component tree that should be handed the context. + +### Providing context with `` ```js // To create a completed empty context @@ -77,7 +82,7 @@ class TodoApp extends React.Component { } ``` -### Consume _context_ from a Class Component +### Consume context from a Class Component Inside the children components, like the `` component, the value can be access from the component's `context` prop like this: @@ -93,7 +98,7 @@ class TodoHeader extends React.Component { TodoHeader.contextType = TodoContext; ``` -### Consume _context_ from a Functional Component +### 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): From 53c1320d6589f475ed7fb932652d5abc6fa9d9b6 Mon Sep 17 00:00:00 2001 From: Ken Date: Sun, 3 Mar 2019 14:08:41 -0800 Subject: [PATCH 16/17] fixing up some grammar in 2.4 --- step2-04/demo/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/step2-04/demo/README.md b/step2-04/demo/README.md index 63cb37b..fda2fc8 100644 --- a/step2-04/demo/README.md +++ b/step2-04/demo/README.md @@ -49,7 +49,7 @@ All of these props are not used, except to be passed down to a child Component, ## React 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. In simpler terms, it solves the props drilling issue above! +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. In simpler terms, it solves the props drilling issue mentioned above! React context is created by calling `createContext()` with some initial data. Use the `` component to wrap a part of the component tree that should be handed the context. @@ -84,7 +84,7 @@ class TodoApp extends React.Component { ### Consume context from a Class Component -Inside the children components, like the `` component, the value can be access from the component's `context` prop like this: +Inside a class-based child component, such as the `` component, the value can be accessed from the component's `context` property like this: ```js class TodoHeader extends React.Component { @@ -100,7 +100,7 @@ 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): +If you're using the functional component syntax, you can access the context with the `useContext()` hook. Note that `useContext()` requires a recent release of React (16.8): ```js const TodoFooter = props => { @@ -112,3 +112,5 @@ const TodoFooter = props => { ); }; ``` + +There is another legal syntax for accessing context with the ``, but we'll leave that out as an exercise for you! From 3acd7d22ec0538eed7205ef33a6d90588b9344c6 Mon Sep 17 00:00:00 2001 From: Micah Godbolt Date: Sun, 3 Mar 2019 17:51:31 -0800 Subject: [PATCH 17/17] updates --- index.html | 23 +-- step1-01/exercise/README.md | 50 +------ step1-01/exercise/index.html | 76 +++++++++- step1-02/README.md | 92 ------------ step1-02/demo/index..html | 29 ---- step1-02/demo/index.html | 132 ++++++++++++++++++ step1-02/demo/style.css | 62 -------- step1-02/exercise/answers.css | 27 ++++ step1-02/exercise/index.html | 56 ++++++++ step1-03/demo/README.md | 12 +- step1-03/demo/index.html | 67 +++++++-- step1-03/exercise/README.md | 9 ++ step1-03/exercise/answer.js | 14 ++ step1-03/exercise/index.html | 73 ++++++---- step1-04/demo/README.md | 44 ++---- step1-05/TodoApp.html | 36 +++++ step1-05/demo/README.md | 79 +++++------ step1-05/demo/src/App.tsx | 7 +- step1-05/demo/src/components/TodoFooter.tsx | 9 -- step1-05/demo/src/components/TodoHeader.tsx | 11 -- step1-05/demo/src/components/TodoList.tsx | 11 -- step1-05/demo/src/components/TodoListItem.tsx | 7 - step1-05/exercise/README.md | 6 +- step1-06/demo/README.md | 4 +- 24 files changed, 511 insertions(+), 425 deletions(-) delete mode 100644 step1-02/demo/index..html create mode 100644 step1-02/demo/index.html delete mode 100644 step1-02/demo/style.css create mode 100644 step1-02/exercise/answers.css create mode 100644 step1-02/exercise/index.html create mode 100644 step1-03/exercise/answer.js create mode 100644 step1-05/TodoApp.html delete mode 100644 step1-05/demo/src/components/TodoFooter.tsx delete mode 100644 step1-05/demo/src/components/TodoHeader.tsx delete mode 100644 step1-05/demo/src/components/TodoList.tsx delete mode 100644 step1-05/demo/src/components/TodoListItem.tsx diff --git a/index.html b/index.html index 9d4da2b..f19aa5c 100644 --- a/index.html +++ b/index.html @@ -16,26 +16,27 @@

    Day 1

    Get set up and learn to build a todo app using HTML, CSS, JavaScript, and React. -
  • - - HTML/CSS/JS - -
  • +
  • +
  • +
  • diff --git a/step1-01/exercise/README.md b/step1-01/exercise/README.md index 34d6476..c69176e 100644 --- a/step1-01/exercise/README.md +++ b/step1-01/exercise/README.md @@ -1,49 +1 @@ -# Step 1 Exercise - -The power of HTML is its ability to represent complex information in a way that conveys meaning. In this exercise you are going to be creating an HTML page for my favorite recipe. - -## The Exercise - -1. Create a recipe page to host our recipe (page title, headings, sections, paragraphs, lists etc) -2. Use title, header, main, footer, headings (h1/h2 etc), paragraphs, lists -3. Use ordered and unordered lists appropriately -4. Add the `baked_beans.jpg` iagine included in this folder -5. Add an anchor tag around 'Wisconsin Beer Brats' - -## 4th of July Baked Beans - -It's great how a single meal can take you back dozens of years. This is one of those recipes that never seems to fail to impress. - -I learned this recipe for the cousin of one of my college friends back in Nashville Tenessee. We had an amazing 4th of July feast which included this recipe and some bratworst like these Wisconsin Beer Brats https://www.culinaryhill.com/wisconsin-beer-brats/ - -Prep Time: 10 minutes -Cook time: 3+ hours -Servings: 12 - -**Ingredients** -1LB Bacon chopped -3 Cans Bush's Origin Baked Beans -1 Walla Wall Onion chopped -2 ground garlic cloves -3 Tablespoons of mustard -2 Tablespoons of molasses -3 Tablespoons of brown sugar - -**Directions** -Cook bacon until it is mostly cooked, then drain most of the grease and put aside -Cook onion in remaining bacon grease -Combine onions and bacon, then add garlic, cook for a few more minutes -Add beans and get up to simmer temperate -Add mustard until your beans are nice and yellow -Add molassas until color darkens again -Add brown sugar until properly sweet -Simmer for a long time, occassionally sturing - -**Expert Tipes** -Burning off most of the liquid gives you nice, hearty, sticky beans. -If the beans get too try, you can always add beer! - -**Nutritional Information** -Calories: lots -Fat: lots -Fun: lots +Readme is in index.html diff --git a/step1-01/exercise/index.html b/step1-01/exercise/index.html index 1895d20..5c30717 100644 --- a/step1-01/exercise/index.html +++ b/step1-01/exercise/index.html @@ -1,4 +1,76 @@ - - + +
    +
    +/*
    +Step 1 Exercise
    +
    +The power of HTML is its ability to represent complex information in a way that conveys meaning. In this exercise you are going to be creating an HTML page for my favorite recipe.
    +
    +## The Exercise
    +
    +1. Create a recipe page to host our recipe
    +2. Use title, header, main, footer, headings (h1/h2 etc), paragraphs, lists
    +3. Use ordered and unordered lists appropriately
    +4. Add the `baked_beans.jpg` image included in this folder: `https://raw.githubusercontent.com/Microsoft/frontend-bootcamp/master/step1-01/exercise/baked_beans.jpg`
    +5. Add an anchor tag around 'Wisconsin Beer Brats'
    +
    +## The Recipe
    +
    +Title:
    +4th of July Baked Beans
    +
    +Description:
    +It's great how a single meal can take you back dozens of years. This is one of those recipes that never seems to fail to impress.
    +
    +I learned this recipe from the cousin of one of my college friends back in Nashville Tennessee. We had an amazing 4th of July feast which included this recipe and some bratwurst like these Wisconsin Beer Brats https://www.culinaryhill.com/wisconsin-beer-brats/
    +
    +Prep Time: 10 minutes
    +Cook time: 3+ hours
    +Servings: 12
    +
    +Ingredients:
    +1LB Bacon chopped
    +3 Cans Bush's Original Baked Beans
    +1 Walla Wall Onion chopped
    +2 ground garlic cloves
    +3 Tablespoons of mustard
    +2 Tablespoons of molasses 
    +3 Tablespoons of brown sugar
    +
    +Directions:
    +Cook bacon until it is mostly cooked, then drain most of the grease and put aside
    +Cook onion in remaining bacon grease
    +Combine onions and bacon, then add garlic, cook for a few more minutes
    +Add beans and get up to simmer temperature
    +Add mustard until your beans are nice and yellow
    +Add molasses until color darkens again
    +Add brown sugar until properly sweet
    +Simmer for a long time, occasionally stirring
    +
    +Expert Tips:
    +Burning off most of the liquid gives you nice, hearty, sticky beans.
    +If the beans get too dry, you can always add beer!
    +
    +Nutritional Information:
    +Calories: lots
    +Fat: lots
    +Fun: lots
    +
    +*/
    +
    +      
    +
    Add Recipe Here
    +
    + + + diff --git a/step1-02/README.md b/step1-02/README.md index 09bf1d0..76a85b1 100644 --- a/step1-02/README.md +++ b/step1-02/README.md @@ -48,98 +48,6 @@ Selectors can be a single tag, class, ID, or attribute. It can also be a [combin Below is a series of selectors and property/value combinations that we'll apply to our CSS demo page. ```css -/* Targeting the entire page */ -body { - font: 1.2em sans-serif; -} - -/* Targeting an HTML tag */ -h1 { - /* Color name */ - color: black; - - /* 6-digit hex */ - background: #ababab; - - /* Margin: specified separately for each side */ - margin-bottom: 15px; - margin-top: 15px; - - /* Shorthand: Padding applies to all sides */ - padding: 10px; - - /* Border shorthand and 3-digit hex */ - border: 1px solid #ddd; -} - -/* Overriding inherited styles */ -span { - color: #004578; -} - -/* Sibling selector */ -a ~ a { - /* Changing elements from inline to block */ - display: block; -} - -/* Targeting a class name */ -.tiles { - display: flex; -} - -/* Descendant selector */ -.tiles img { - width: 100%; -} - -/* Direct descendant selector */ -.tiles > div { - /* rgb color */ - background: rgb(10, 10, 10); - color: white; - flex-basis: 100%; - /* Padding/margin shorthand. Goes clockwise from top. - 10px - all - 10px 20px - top/bottom left/right - 10px 20px 15px - top left/right bottom - */ - padding: 10px 20px 15px; - margin: 10px 20px 10px 0; - border: 1px solid white; -} - -/* Qualified selector */ -div.important-links { - background: #004578; -} - -/* Style inheritance only works for unstyled elements */ -a { - color: white; -} - -/* Hover pseudo-selector */ -a:hover { - color: #ccc; -} - -/* Positional pseudo-selector */ -.tiles > div:last-child { - /* overrides margin-right but leaves other margins alone */ - margin-right: 0; -} - -/* ID selector */ -#contact-form { - display: flex; - flex-direction: column; -} - -/* Attribute selector */ -input[type='submit'] { - margin-top: 10px; -} ``` ## Next Step diff --git a/step1-02/demo/index..html b/step1-02/demo/index..html deleted file mode 100644 index 54e6eda..0000000 --- a/step1-02/demo/index..html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - -
    -

    This is my Title

    -
    - -
    -

    Our Logo

    - fabric logo -
    -
    -

    Contact Us

    -
    - - -
    -
    -
    -
    - - diff --git a/step1-02/demo/index.html b/step1-02/demo/index.html new file mode 100644 index 0000000..3c78bc1 --- /dev/null +++ b/step1-02/demo/index.html @@ -0,0 +1,132 @@ + + +
    +
    +/* Targeting the entire page */
    +//body {
    +  font: 1.2em sans-serif;
    +}
    +
    +/* Targeting an HTML tag */
    +//h1 {
    +  /* Color name */
    +  color: black;
    +
    +  /* 6-digit hex  */
    +  background: #ababab;
    +
    +  /* Margin: specified separately for each side */
    +  margin-bottom: 15px;
    +  margin-top: 15px;
    +
    +  /* Shorthand: Padding applies to all sides  */
    +  padding: 10px;
    +
    +  /* Border shorthand and 3-digit hex */
    +  border: 1px solid #ddd;
    +}
    +
    +/* Overriding inherited styles */
    +//span {
    +  color: #004578;
    +}
    +
    +/* Sibling selector */
    +//a + a {
    +  /* Changing elements from inline to block */
    +  display: block;
    +}
    +
    +/* Targeting a class name  */
    +//.tiles {
    +  display: flex;
    +}
    +
    +/* Descendant selector */
    +//.tiles img {
    +  width: 100%;
    +}
    +
    +/* Direct descendant selector */
    +//.tiles > div {
    +  /* rgb color */
    +  background: rgb(10, 10, 10);
    +  color: white;
    +  flex-basis: 100%;
    +  /* Padding/margin shorthand. Goes clockwise from top.
    +  10px - all
    +  10px 20px - top/bottom left/right
    +  10px 20px 15px - top left/right bottom
    +  */
    +  padding: 10px 20px 15px;
    +  margin: 10px 20px 10px 0;
    +  border: 1px solid white;
    +}
    +
    +/* Qualified selector */
    +//div.important-links {
    +  background: #004578;
    +}
    +
    +/* Style inheritance only works for unstyled elements */
    +//a {
    +  color: white;
    +}
    +
    +/* Hover pseudo-selector */
    +//a:hover {
    +  color: #ccc;
    +}
    +
    +/* Positional pseudo-selector  */
    +//.tiles > div:last-child {
    +  /* overrides margin-right but leaves other margins alone */
    +  margin-right: 0;
    +}
    +
    +/* ID selector */
    +//#contact-form {
    +  display: flex;
    +  flex-direction: column;
    +}
    +
    +/* Attribute selector */
    +//input[type='submit'] {
    +  margin-top: 10px;
    +}   
    +      
    +
    +<h1>This is my <span>Title</span></h1>
    +<div class="tiles">
    +  <div class="important-links">
    +    <h2>Important Links</h2>
    +    <a href="#">We're Awesome</a>
    +    <a href="#">Learn More</a>
    +    <a href="#">Hire Us</a>
    +  </div>
    +  <div>
    +    <h2>Our Logo</h2>
    +    <img src="../../assets/fabric.jpg" width="100" alt="fabric logo" />
    +  </div>
    +  <div>
    +    <h2>Contact Us</h2>
    +    <div id="contact-form">
    +      <label>Email</label><input type="email" />
    +      <input value="Submit" type="submit" />
    +    </div>
    +  </div>
    +</div>
    +      
    +
    + + + + diff --git a/step1-02/demo/style.css b/step1-02/demo/style.css deleted file mode 100644 index 35bb9de..0000000 --- a/step1-02/demo/style.css +++ /dev/null @@ -1,62 +0,0 @@ -/* body { - font: 1.2em sans-serif; -} */ - -/* h1 { - color: black; - background: #ababab; - margin-bottom: 15px; - margin-top: 15px; - padding: 10px; - border: 1px solid #ddd; -} */ - -/* span { - color: #004578; -} */ - -/* a ~ a { - display: block; -} */ - -/* .tiles { - display: flex; -} */ - -/* .tiles img { - width: 100%; -} */ - -/* .tiles > div { - background: rgb(10, 10, 10); - color: white; - flex-basis: 100%; - padding: 10px 20px 15px; - margin: 10px 20px 10px 0; - border: 1px solid white; -} */ - -/* div.important-links { - background: #004578; -} */ - -/* a { - color: white; -} */ - -/* a:hover { - color: #ccc; -} */ - -/* .tiles > div:last-child { - margin-right: 0; -} */ - -/* #contact-form { - display: flex; - flex-direction: column; -} */ - -/* input[type='submit'] { - margin-top: 10px; -} */ diff --git a/step1-02/exercise/answers.css b/step1-02/exercise/answers.css new file mode 100644 index 0000000..7648302 --- /dev/null +++ b/step1-02/exercise/answers.css @@ -0,0 +1,27 @@ +h2 { + color: red; +} + +h2 + div { + color: green; +} + +.myList li { + border: 1px solid green; +} + +.myClass { + background: green; +} + +#myId { + background: yellow; +} + +.myClass.otherClass { + color: white; +} + +section > div:last-child { + border: 1px solid pink; +} diff --git a/step1-02/exercise/index.html b/step1-02/exercise/index.html new file mode 100644 index 0000000..02cf8d4 --- /dev/null +++ b/step1-02/exercise/index.html @@ -0,0 +1,56 @@ + + +
    +
    +/* 1. */ 
    +
    +/* 2. */ 
    +
    +/* 3. */ 
    +
    +/* 4. */
    +
    +/* 5. */ 
    +
    +/* 6. */ 
    +
    +/* Bonus */
    +      
    +
    +<section>
    +  <h2>1. Text Color: Red</h2>
    +  <div>2. Color Green (hint: Sibling Selector)</div>
    +    <ul class="myList">
    +      <li>
    +      3. Border Green
    +      </li>
    +    </ul>
    +  <main>
    +  <div class="myClass">4. Background Green</div>
    +  <div class="myClass otherClass">
    +    5. Background Green & Color White 
    +    (Hint Qualified Selector)
    +    </div>
    +  <div id="myId" class="otherClass">6. Background Yellow</div>
    +  </main>
    +  <ul>
    +      <li>
    +          Don't Style Me
    +      </li>
    +    </ul>
    +  <div>Bonus: Border Pink</div>
    +</section>
    +      
    +
    + + + + diff --git a/step1-03/demo/README.md b/step1-03/demo/README.md index 74eb6dd..e8475b2 100644 --- a/step1-03/demo/README.md +++ b/step1-03/demo/README.md @@ -52,11 +52,10 @@ let myFunction = function(myNumberParam) { ### Adding Variables -Let's start off our demo by adding some variables to our [script tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script). The other examples on this page will reference these variables. +Let's start off our demo by adding a variable to our [script tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script). This variable will be global and constant. ```js const match = 'a'; -let matches = 0; ``` ## Functions @@ -65,6 +64,8 @@ Functions are reusable pieces of functionality. Functions can take inputs (param In our example we'll create a function called `displayMatches` (camelCase is typical for functions) and we'll invoke this function every time that our submit button is clicked. For now we'll simply have our function call `alert("I'm Clicked")`, which is a function that creates an alert in your browser. +> Note that we want to place `matches` inside of the function so that it resets to 0 each time the function is called + ```js function displayMatches() { alert("I'm Clicked"); @@ -111,14 +112,14 @@ window.onclick = function() { In our example we want to trigger a function based on the click of a button. To do this, we first need to get a reference to the button. We can use `querySelector` to get that reference. And then we can set its `onclick` value just like above. ```js -const button = docment.querySelector('.submit'); +const button = document.querySelector('.submit'); button.onclick = displayMatches(); ``` You can also combine these together like this: ```js -docment.querySelector('.submit').onclick = displayMatches(); +docment.querySelector('.submit').onclick = displayMatches; ``` Wire this up and see you function in action! @@ -138,11 +139,12 @@ function displayMatches() { ## Conditionals -Next we want to compare each `letter` with our `match` value, and if they are the same, we will increment our `matches` variable. Remember that `letter = match` would set the `letter` variable to the value in `match`, so to do comparisons, we use the equality operator `==` or the strict equality operator `===`. +Next we want to compare each `letter` with our global `match` value, and if they are the same, we will increment a `matches` variable. Remember that `letter = match` would set the `letter` variable to the value in `match`, so to do comparisons, we use the equality operator `==` or the strict equality operator `===`. ```js function displayMatches() { const text = 'abcda'; + let matches = 0; for (let letter of text) { if (letter === match) { matches++; diff --git a/step1-03/demo/index.html b/step1-03/demo/index.html index e46faab..c1f6b3d 100644 --- a/step1-03/demo/index.html +++ b/step1-03/demo/index.html @@ -1,19 +1,58 @@ - - - -
    -
    -
    -

    Contact Us

    -
    - - -
    -
    -
    +
    +
    +
    +body {
    +  font: 1.2em sans-serif;
    +}
    +
    +.tiles > div {
    +  background: rgb(10, 10, 10);
    +  color: white;
    +  flex-basis: 100%;
    +  padding: 10px 20px 15px;
    +  margin: 10px 20px 10px 0;
    +  border: 1px solid white;
    +}
    +
    +#contact-form {
    +  display: flex;
    +  flex-direction: column;
    +}
    +
    +input[type='submit'] {
    +  align-self: flex-start;
    +  margin-top: 10px;
    +}   
    +      
    +
    +<div class="tiles">
    +  <div>
    +    <h2>Contact Us</h2>
    +    <div id="contact-form">
    +      <label>Email</label><input id="email" type="email" />
    +      <input class="submit" value="Submit" type="submit" />
    +    </div>
    +  </div>
    +</div>
    +      
    +
    +// Count the number of 'a' characters in the email form. 
    +// Update button to reflect that number.  
    +
    +
    + + - diff --git a/step1-03/exercise/README.md b/step1-03/exercise/README.md index e69de29..08f0e67 100644 --- a/step1-03/exercise/README.md +++ b/step1-03/exercise/README.md @@ -0,0 +1,9 @@ +1. Create a function named `getFavs` and set its contents to `alert('clicked')` +2. Create a variable `button` and set it to a reference to our button by using `document.querySelector('button')` +3. Add a click event listener to the button that calls `getFavs`. Click the button and make sure it calls our alert. +4. Replace the alert with a new `favList` variable set to an empty array: `[]` +5. Create a const variable `inputs` set to all of the inputs on the page. `querySelectorAll` will help here +6. Iterate over all of the inputs using `for (const input of inputs) {}` +7. For each iteration use an `if` statement to check if `input.checked` is equal to true +8. If the above tests passes, push the `input.parentNode.textContent` onto the `favList` array. Pass that text into `favList.push()` as the parameter to add it to the array +9. Outside of the for loop, use `document.querySelector('.favorites')` to target the div at the bottom of the page. Set the div's `textContent` to `favList.join(' ')`. This will join each of the foods together into a string separated by a space. diff --git a/step1-03/exercise/answer.js b/step1-03/exercise/answer.js new file mode 100644 index 0000000..e809728 --- /dev/null +++ b/step1-03/exercise/answer.js @@ -0,0 +1,14 @@ +function getFavs() { + let favList = []; + const inputs = document.querySelectorAll('input'); + for (const input of inputs) { + if (input.checked === true) { + favList.push(input.parentNode.textContent); + } + } + document.querySelector('.favorites').textContent = favList.join(' '); +} + +let button = document.querySelector('button'); + +button.addEventListener('click', getFavs); diff --git a/step1-03/exercise/index.html b/step1-03/exercise/index.html index 8714ec4..1315cb5 100644 --- a/step1-03/exercise/index.html +++ b/step1-03/exercise/index.html @@ -1,37 +1,48 @@ - - - - - - +
    +
    +label,
    +button {
    +  display: block;
    +  margin-bottom: 10px;
    +}
    +      
    +
    +<label><input type="checkbox" />Ice cream</label>
    +<label><input type="checkbox" />Pizza</label>
    +<label><input type="checkbox" />Tacos</label>
    +<label><input type="checkbox" />Meatloaf</label>
    +<label><input type="checkbox" />Brocolli</label>
     
    -    
    +<button>Display Your Favorites</button>
     
    -    
    +<div class="favorites"></div> +
    +
    +/*        
    +1. Create a function named `getFavs` and set its contents to `alert('clicked')`
    +2. Create a variable `button` and set it to a reference to our button by using `document.querySelector('button')`
    +3. Add a click event listener to the button that calls `getFavs`. Click the button and make sure it calls our alert.
    +4. Replace the alert with a new `favList` variable set to an empty array: `[]`
    +5. Create a const variable `inputs` set to all of the inputs on the page. `querySelectorAll` will help here
    +6. Iterate over all of the inputs using `for (const input of inputs) {}`
    +7. For each iteration use an `if` statement to check if `input.checked` is equal to true
    +8. If the above tests passes, push the `input.parentNode.textContent` onto the `favList` array. Pass that text into `favList.push()` as the parameter to add it to the array
    +9. Outside of the for loop, use `document.querySelector('.favorites')` to target the div at the bottom of the page. Set the div's `textContent` to `favList.join(' ')`. This will join each of the foods together into a string separated by a space.
    +*/
    +
    +      
    +
    + + - - diff --git a/step1-04/demo/README.md b/step1-04/demo/README.md index 2ab9eec..68ba0e2 100644 --- a/step1-04/demo/README.md +++ b/step1-04/demo/README.md @@ -1,36 +1,10 @@ # Step 4 - Introduction To React Demo -In our last example we saw how we could take a static HTML page and turn it into an interactive page with some buttons and their `onclick` handlers. - -In this example we'll see how React turns that paradigm completely around. With React, the entire DOM is generated and maintained by JavaScript, directly inside the browser. This makes it easier to assemble your application out of reusable pieces, maintain state within a component, and pass data between them. - In this demo we'll be creating a simple counter that will display a count and increment on click. -## Building the App +## React Hello World -If you already have the app running from a previous step, stop it with `ctrl+C`. Start the app version used in this step by running `npm start` from the root of the `frontend-bootcamp` folder. Click the "demo" link under day 1 step 4 to see results. - -## index.html - -The `index.html` file in a React project is going to be pretty mimimal. React is loading all of the application onto the page, so the only content you'll find in the page source is a div with an `id` of "app". - -> Note that our bundling tool, webpack, is adding this script tag to the HTML file we provided - -```html - - - -
    - - - -``` - -## index.tsx - -This is the entry point to your application. - -```ts +```js import React from 'react'; import ReactDOM from 'react-dom'; ReactDOM.render(

    Hello World

    , document.getElementById('app')); @@ -90,9 +64,9 @@ export class App extends React.Component { ## Counter Component -In this example we'll start with an already scaffolded-out control. The goal of our counter is to track how many times the counter button is clicked. In the past JavaScript demo we might have accessed the counter element using `document.querySelector('.counter')` and manually incremented the number found there. While using the DOM as your data store works, it's REALLY hard to scale past the most basic demo. +In this example we'll start with an already scaffolded-out control. The goal of our counter is to track how many times the counter button is clicked. -React solves this by allowing each control to specify its own data store, called **state**. We can reference values in state when we render our UI, and we can also update state over the lifetime of our application. +React allows each control to specify its own data store, called **state**. We can reference values in state when we render our UI, and we can also update state over the lifetime of our application. ### Adding State @@ -205,11 +179,13 @@ This function will update our component's state, incrementing the counter value ```jsx _onButtonClick = () => { - this.setState(prevState => ({ counter: prevState.counter + 1 })); + this.setState({ + counter: this.state.counter + 1 + }); }; ``` -> This isn't exactly a method, but a class property that is set to an [arrow function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions). This mostly works the same as `onButtonClick() { }` but eliminates the need for [extra boilerplate](https://medium.freecodecamp.org/this-is-why-we-need-to-bind-event-handlers-in-class-components-in-react-f7ea1a6f93eb) used to avoid potential "gotchas" with [how `this` works in JavaScript](https://codeburst.io/javascript-the-keyword-this-for-beginners-fb5238d99f85).) +> Note that this could also be written as `this.setState(prevState => ({ counter: prevState.counter + 1 }));` to ensure that state is not updated until the previous state has been determined Now that we have a function to increment our count, all that's left is to connect it to our button. @@ -217,11 +193,9 @@ Now that we have a function to increment our count, all that's left is to connec ``` -> Note the syntax is a bit different than in HTML: `onclick="funcName()"` in HTML vs `onClick={this.funcName}` in JSX. - > Also note that each `Counter` maintains its own state! You can modify the state inside of one counter without affecting the others. -## Bonus: Using a Button component +## Using a Button component Buttons are among the most commonly written components. Custom buttons help abstract common styling, add icons or other decorations, and increase functionality (menu buttons etc). Let's take a quick look at a custom button component to see how it comes together. diff --git a/step1-05/TodoApp.html b/step1-05/TodoApp.html new file mode 100644 index 0000000..44b2d66 --- /dev/null +++ b/step1-05/TodoApp.html @@ -0,0 +1,36 @@ + + + +
    +

    todos

    +
    + + +
    + +
    + +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    + 4 items left + +
    + + diff --git a/step1-05/demo/README.md b/step1-05/demo/README.md index 33194f3..cbbe127 100644 --- a/step1-05/demo/README.md +++ b/step1-05/demo/README.md @@ -1,7 +1,5 @@ # Building a Static Page -## Demo - To start off our todo application we are going to follow the steps outlined in [Thinking in React](https://reactjs.org/docs/thinking-in-react.html). The first step of the process is to break our application into a component hierarchy. For this app, we're going to keep it simple and just use four parts. - TodoHeader @@ -9,66 +7,57 @@ To start off our todo application we are going to follow the steps outlined in [ - TodoListItem - TodoFooter -We could go a lot deeper into creating buttons, inputs and checkboxes, but this is a great place to start. Often you'll want to start with a single large control and then break it up into smaller pieces. +You can find the HTML for our application in `step1-05/TodoApp.html` -### TodoApp +## TodoHeader + +We are going to store all of our components inside of a `components` folder. Lets create that now. We'll then start with the `TodoHeader` inside of a file called `TodoHeader.tsx`. This file format tells our application that this file includes React code written in Typescript. + +> We'll talk about Typescript soon, but for now know that all valid JavaScript is valid Typescript ```jsx import React from 'react'; -import { TodoFooter } from './components/TodoFooter'; -import { TodoHeader } from './components/TodoHeader'; -import { TodoList } from './components/TodoList'; -export class TodoApp extends React.Component { +export class TodoHeader extends React.Component { render() { return ( -
    - - - -
    +
    +

    todos

    +
    + + +
    + +
    ); } } ``` -We'll start off with all of the files scaffolded and imported into our App. This will let us dive right into each control and see updates quickly. - -### TodoHeader - -Our objective for now is to create a static version of our application, so we'll copy over the entire header tag from a previous step, minus any function calls we added. - > Note that since this is React we had to change `class` to `className`, but nothing else changes. -```jsx -return ( -
    -

    todos

    -
    - - -
    - -
    -); -``` - -### TodoListItem +## TodoListItem Any time you see repeated complex elements, that is usually a sign that you should create a new component. With a few props you can typically abstract all of those elements into a single component. This is certainly the case with todo items. ```jsx -return ( -
  • - -
  • -); +import React from 'react'; + +export class TodolistItem extends React.Component { + render() { + return ( +
  • + +
  • + ); + } +} ``` -> Note that I've removed the title span as it was only needed to make targeting that text easier. +> Note that this control could also be created as a function instead of a class: `export const TodoListItem = (props) => {}` diff --git a/step1-05/demo/src/App.tsx b/step1-05/demo/src/App.tsx index 6e797ed..190c0e1 100644 --- a/step1-05/demo/src/App.tsx +++ b/step1-05/demo/src/App.tsx @@ -1,15 +1,10 @@ import React from 'react'; -import { TodoFooter } from './components/TodoFooter'; -import { TodoHeader } from './components/TodoHeader'; -import { TodoList } from './components/TodoList'; export class TodoApp extends React.Component { render() { return (
    - - - +

    Hello World

    ); } diff --git a/step1-05/demo/src/components/TodoFooter.tsx b/step1-05/demo/src/components/TodoFooter.tsx deleted file mode 100644 index a3044c1..0000000 --- a/step1-05/demo/src/components/TodoFooter.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export const TodoFooter = (props: any) => { - return ( -
    -
    Footer
    -
    - ); -}; diff --git a/step1-05/demo/src/components/TodoHeader.tsx b/step1-05/demo/src/components/TodoHeader.tsx deleted file mode 100644 index 013b567..0000000 --- a/step1-05/demo/src/components/TodoHeader.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -export class TodoHeader extends React.Component { - render() { - return ( -
    -
    Header
    -
    - ); - } -} diff --git a/step1-05/demo/src/components/TodoList.tsx b/step1-05/demo/src/components/TodoList.tsx deleted file mode 100644 index 1afff2b..0000000 --- a/step1-05/demo/src/components/TodoList.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -export class TodoList extends React.Component { - render() { - return ( -
    - List -
    - ); - } -} diff --git a/step1-05/demo/src/components/TodoListItem.tsx b/step1-05/demo/src/components/TodoListItem.tsx deleted file mode 100644 index 1b7fb5c..0000000 --- a/step1-05/demo/src/components/TodoListItem.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -export class TodoListItem extends React.Component { - render() { - return
    ; - } -} diff --git a/step1-05/exercise/README.md b/step1-05/exercise/README.md index 06a6990..4b9bed1 100644 --- a/step1-05/exercise/README.md +++ b/step1-05/exercise/README.md @@ -1,14 +1,12 @@ ## Exercise -If you already have the app running from a previous step, stop it with `ctrl+C`. Start the app version used in this step by running `npm start` from the root of the `frontend-bootcamp` folder. Click the "exercise" link under day 1 step 5 to see results. - ### TodoFooter -1. Update the TodoFooter component, copying over the `
    ` tag and all of its children +1. Add a TodoFooter component, copying over the `
    ` tag and all of its children from `TodoApp.html` in the `step1-05` folder. 2. Remove any `onclick` properties, and change `class` to `className` ### TodoList -1. Update the TodoList component like you did with the footer. +1. Add a TodoList component like you did with the footer. 2. Import TodoListItem and add 4 of them inside of the `
      ` 3. Bonus points for using a [`for`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Loops_and_iteration) loop or using [`map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) to create 4 list items based on the array `[1,2,3,4]` diff --git a/step1-06/demo/README.md b/step1-06/demo/README.md index dacd424..b7ca915 100644 --- a/step1-06/demo/README.md +++ b/step1-06/demo/README.md @@ -46,7 +46,7 @@ constructor(props) { To avoid reaching into state over and over, we once again use destructuring to pull out the pieces we need. ```jsx -const { filter, todos = [] } = this.state; +const { filter, todos } = this.state; ``` > Note that I've set `todos` to default to an empty array so that the `todos` variable is never undefined @@ -69,7 +69,7 @@ I've already pulled out our props into `filter` and `todos` variables, and writt ```jsx { - filteredTodos.map(id => ) + filteredTodos.map(id => ); } ```