├── .nvmrc ├── src ├── index.css ├── 00-begin │ ├── App.js │ ├── README.md │ └── api.js ├── index.js ├── 01-jsx │ ├── App.js │ ├── api.js │ └── README.md ├── 02-query-field │ ├── App.js │ ├── api.js │ └── README.md ├── 09-custom-hook │ ├── App.js │ ├── ResultsItem.js │ ├── useGiphy.js │ ├── Results.js │ ├── api.js │ ├── SearchForm.js │ └── README.md ├── 06-components │ ├── Results.js │ ├── ResultsItem.js │ ├── App.js │ ├── api.js │ ├── SearchForm.js │ └── README.md ├── 10-loading-states │ ├── App.js │ ├── ResultsItem.js │ ├── Results.js │ ├── useGiphy.js │ ├── api.js │ ├── SearchForm.js │ └── README.md ├── end │ ├── README.md │ ├── App.js │ ├── ResultsItem.js │ ├── Results.js │ ├── useGiphy.js │ ├── api.js │ └── SearchForm.js ├── 07-prop-types │ ├── App.js │ ├── Results.js │ ├── ResultsItem.js │ ├── api.js │ ├── SearchForm.js │ └── README.md ├── 08-search-focus │ ├── App.js │ ├── ResultsItem.js │ ├── Results.js │ ├── api.js │ ├── SearchForm.js │ └── README.md ├── 03-api │ ├── App.js │ ├── api.js │ └── README.md ├── 04-lists │ ├── App.js │ ├── api.js │ └── README.md ├── 05-form-submit │ ├── api.js │ ├── App.js │ └── README.md └── quiz │ └── README.md ├── netlify.toml ├── public ├── favicon.ico └── index.html ├── .prettierrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── package.json ├── CODE_OF_CONDUCT.md ├── scripts └── setup └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.14.1 2 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 1rem; 4 | } 5 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "build/" 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmvp/react-workshop/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/00-begin/App.js: -------------------------------------------------------------------------------- 1 | const App = () => { 2 | return null 3 | } 4 | 5 | export default App 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import App from './end/App' 4 | 5 | import './index.css' 6 | 7 | render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /src/01-jsx/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const App = () => { 4 | return ( 5 |
6 |

Giphy Search!

7 |
8 | ) 9 | } 10 | 11 | export default App 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | main: 7 | name: Test app build 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout repo 12 | uses: actions/checkout@v1 13 | 14 | - name: Use Node 12 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | 19 | - name: Install NPM dependencies 20 | run: npm ci 21 | 22 | - name: Run app build 23 | run: npm run build 24 | -------------------------------------------------------------------------------- /src/02-query-field/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | const App = () => { 4 | const [inputValue, setInputValue] = useState('') 5 | 6 | return ( 7 |
8 |

Giphy Search!

9 | 10 |
11 | { 16 | setInputValue(e.target.value) 17 | }} 18 | /> 19 |
20 |
21 | ) 22 | } 23 | 24 | export default App 25 | -------------------------------------------------------------------------------- /src/09-custom-hook/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useGiphy from './useGiphy' 3 | import Results from './Results' 4 | import SearchForm from './SearchForm' 5 | 6 | const App = () => { 7 | const [results, setSearchParams] = useGiphy() 8 | 9 | return ( 10 |
11 |

Giphy Search!

12 | 13 | 18 | 19 |
20 | ) 21 | } 22 | 23 | export default App 24 | -------------------------------------------------------------------------------- /src/06-components/Results.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ResultsItem from './ResultsItem' 3 | 4 | const Results = ({ items }) => { 5 | return ( 6 | items.length > 0 && ( 7 |
8 | {items.map((item) => ( 9 | 17 | ))} 18 |
19 | ) 20 | ) 21 | } 22 | 23 | export default Results 24 | -------------------------------------------------------------------------------- /src/10-loading-states/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useGiphy from './useGiphy' 3 | import Results from './Results' 4 | import SearchForm from './SearchForm' 5 | 6 | const App = () => { 7 | const [{ status, results, error }, setSearchParams] = useGiphy() 8 | 9 | return ( 10 |
11 |

Giphy Search!

12 | 13 | 18 | 19 |
20 | ) 21 | } 22 | 23 | export default App 24 | -------------------------------------------------------------------------------- /src/06-components/ResultsItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => { 4 | return ( 5 |
14 |
24 | ) 25 | } 26 | 27 | export default ResultsItem 28 | -------------------------------------------------------------------------------- /src/end/README.md: -------------------------------------------------------------------------------- 1 | # The End 2 | 3 | Congratulations! 🏆 4 | 5 | You've reached the end of the React FUNdamentals workshop, learning JSX, `useState`, `useEffect`, custom hooks, and so much more! 6 | 7 | Hopefully, you'll get to apply this learning right away at your job or in a side project. But if not, I suggest you **come back to this workshop in a week or two** and review the content. Your brain likes to garbage collect unused knowledge and we want this to stick around! The content is all open-source. 8 | 9 | ## ❤️ Workshop Feedback 10 | 11 | Feedback is a gift. 🎁 Now that you're done with the workshop, I would greatly appreciate your overall feedback on the workshop to help make it even better for the next learners. **[Share your workshop feedback](https://bit.ly/react-fun-ws-feedback)**. 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # Webpack build folders 36 | **/build/ 37 | **/dist 38 | 39 | # Workshop directory (and backups) 40 | src/workshop* 41 | 42 | # Editor settings 43 | .vscode 44 | -------------------------------------------------------------------------------- /src/00-begin/README.md: -------------------------------------------------------------------------------- 1 | # Step 0 - Begin React Workshop 2 | 3 | 🏅 The goal of this step is to ensure you have everything set up with a running (but blank) app. We will be working in a step-by-step fashion to build a [Giphy search app](https://react-workshop.benmvp.com/). 4 | 5 | ## 📝 Tasks 6 | 7 | Complete the [setup instructions](../../README.md#setup)! It's your **last chance**! 🏃🏾‍♂️ 8 | 9 | If you ran the setup **before today**, pull any changes to the repo and re-run the setup to ensure that you have the most up-to-date code examples: 10 | 11 | ```sh 12 | git pull --rebase=false 13 | npm run setup 14 | ``` 15 | 16 | This should run pretty quickly. 17 | 18 | Finally, run the app if you haven't already! 19 | 20 | ```sh 21 | npm start 22 | ``` 23 | 24 | Let's get started! 🎉 25 | 26 | ## 👉🏾 Next Step 27 | 28 | Go to [Step 1 - JSX](../01-jsx/). 29 | -------------------------------------------------------------------------------- /src/06-components/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { getResults } from './api' 3 | import Results from './Results' 4 | import SearchForm from './SearchForm' 5 | 6 | const App = () => { 7 | const [formValues, setFormValues] = useState({}) 8 | const [results, setResults] = useState([]) 9 | 10 | useEffect(() => { 11 | const fetchResults = async () => { 12 | try { 13 | const apiResponse = await getResults(formValues) 14 | 15 | setResults(apiResponse.results) 16 | } catch (err) { 17 | console.error(err) 18 | } 19 | } 20 | 21 | fetchResults() 22 | }, [formValues]) 23 | 24 | return ( 25 |
26 |

Giphy Search!

27 | 28 | 29 | 30 |
31 | ) 32 | } 33 | 34 | export default App 35 | -------------------------------------------------------------------------------- /src/07-prop-types/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { getResults } from './api' 3 | import Results from './Results' 4 | import SearchForm from './SearchForm' 5 | 6 | const App = () => { 7 | const [formValues, setFormValues] = useState({}) 8 | const [results, setResults] = useState([]) 9 | 10 | useEffect(() => { 11 | const fetchResults = async () => { 12 | try { 13 | const apiResponse = await getResults(formValues) 14 | 15 | setResults(apiResponse.results) 16 | } catch (err) { 17 | console.error(err) 18 | } 19 | } 20 | 21 | fetchResults() 22 | }, [formValues]) 23 | 24 | return ( 25 |
26 |

Giphy Search!

27 | 28 | 33 | 34 |
35 | ) 36 | } 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /src/08-search-focus/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { getResults } from './api' 3 | import Results from './Results' 4 | import SearchForm from './SearchForm' 5 | 6 | const App = () => { 7 | const [formValues, setFormValues] = useState({}) 8 | const [results, setResults] = useState([]) 9 | 10 | useEffect(() => { 11 | const fetchResults = async () => { 12 | try { 13 | const apiResponse = await getResults(formValues) 14 | 15 | setResults(apiResponse.results) 16 | } catch (err) { 17 | console.error(err) 18 | } 19 | } 20 | 21 | fetchResults() 22 | }, [formValues]) 23 | 24 | return ( 25 |
26 |

Giphy Search!

27 | 28 | 33 | 34 |
35 | ) 36 | } 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /src/end/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useGiphy from './useGiphy' 3 | import Results from './Results' 4 | import SearchForm from './SearchForm' 5 | 6 | const App = () => { 7 | const [{ status, results, error }, setSearchParams] = useGiphy() 8 | 9 | return ( 10 |
11 |

Giphy Search!

12 | 13 | 18 | 19 | 20 |
21 | 22 |

23 | This is the app for the{' '} 24 | 29 | React FUNdamentals Workshop with Ben Ilegbodu 30 | 31 | . 32 |

33 |
34 | ) 35 | } 36 | 37 | export default App 38 | -------------------------------------------------------------------------------- /src/03-api/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { getResults } from './api' 3 | 4 | const App = () => { 5 | const [inputValue, setInputValue] = useState('') 6 | const [results, setResults] = useState([]) 7 | 8 | useEffect(() => { 9 | const fetchResults = async () => { 10 | try { 11 | const apiResponse = await getResults({ searchQuery: inputValue }) 12 | 13 | setResults(apiResponse.results) 14 | } catch (err) { 15 | console.error(err) 16 | } 17 | } 18 | 19 | fetchResults() 20 | }, [inputValue]) 21 | 22 | console.log({ inputValue, results }) 23 | 24 | return ( 25 |
26 |

Giphy Search!

27 | 28 |
29 | { 34 | setInputValue(e.target.value) 35 | }} 36 | /> 37 |
38 |
39 | ) 40 | } 41 | 42 | export default App 43 | -------------------------------------------------------------------------------- /src/07-prop-types/Results.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import ResultsItem from './ResultsItem' 4 | 5 | const Results = ({ items }) => { 6 | return ( 7 | items.length > 0 && ( 8 |
9 | {items.map((item) => ( 10 | 18 | ))} 19 |
20 | ) 21 | ) 22 | } 23 | 24 | Results.propTypes = { 25 | items: PropTypes.arrayOf( 26 | PropTypes.shape({ 27 | id: PropTypes.string.isRequired, 28 | title: PropTypes.string.isRequired, 29 | url: PropTypes.string.isRequired, 30 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 31 | previewUrl: PropTypes.string.isRequired, 32 | }), 33 | ).isRequired, 34 | } 35 | 36 | export default Results 37 | -------------------------------------------------------------------------------- /src/end/ResultsItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => { 5 | return ( 6 |
15 |
25 | ) 26 | } 27 | 28 | ResultsItem.propTypes = { 29 | id: PropTypes.string.isRequired, 30 | title: PropTypes.string.isRequired, 31 | url: PropTypes.string.isRequired, 32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 33 | previewUrl: PropTypes.string.isRequired, 34 | } 35 | 36 | export default ResultsItem 37 | -------------------------------------------------------------------------------- /src/07-prop-types/ResultsItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => { 5 | return ( 6 |
15 |
25 | ) 26 | } 27 | 28 | ResultsItem.propTypes = { 29 | id: PropTypes.string.isRequired, 30 | title: PropTypes.string.isRequired, 31 | url: PropTypes.string.isRequired, 32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 33 | previewUrl: PropTypes.string.isRequired, 34 | } 35 | 36 | export default ResultsItem 37 | -------------------------------------------------------------------------------- /src/08-search-focus/ResultsItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => { 5 | return ( 6 |
15 |
25 | ) 26 | } 27 | 28 | ResultsItem.propTypes = { 29 | id: PropTypes.string.isRequired, 30 | title: PropTypes.string.isRequired, 31 | url: PropTypes.string.isRequired, 32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 33 | previewUrl: PropTypes.string.isRequired, 34 | } 35 | 36 | export default ResultsItem 37 | -------------------------------------------------------------------------------- /src/09-custom-hook/ResultsItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => { 5 | return ( 6 |
15 |
25 | ) 26 | } 27 | 28 | ResultsItem.propTypes = { 29 | id: PropTypes.string.isRequired, 30 | title: PropTypes.string.isRequired, 31 | url: PropTypes.string.isRequired, 32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 33 | previewUrl: PropTypes.string.isRequired, 34 | } 35 | 36 | export default ResultsItem 37 | -------------------------------------------------------------------------------- /src/10-loading-states/ResultsItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => { 5 | return ( 6 |
15 |
25 | ) 26 | } 27 | 28 | ResultsItem.propTypes = { 29 | id: PropTypes.string.isRequired, 30 | title: PropTypes.string.isRequired, 31 | url: PropTypes.string.isRequired, 32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 33 | previewUrl: PropTypes.string.isRequired, 34 | } 35 | 36 | export default ResultsItem 37 | -------------------------------------------------------------------------------- /src/09-custom-hook/useGiphy.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { getResults } from './api' 3 | 4 | /** 5 | * @typedef {import('./api').SearchParams} SearchParams 6 | * @typedef {import('./api').GiphyResult} GiphyResult 7 | * 8 | * @callback SetSearchParams 9 | * @param {SearchParams} searchParams Search parameters 10 | */ 11 | 12 | /** 13 | * A custom hook that returns giphy results and a function to search for updated results 14 | * @returns {[GiphyResult[], SetSearchParams]} 15 | */ 16 | const useGiphy = () => { 17 | const [searchParams, setSearchParams] = useState({}) 18 | const [results, setResults] = useState([]) 19 | 20 | useEffect(() => { 21 | const fetchResults = async () => { 22 | try { 23 | const apiResponse = await getResults(searchParams) 24 | 25 | setResults(apiResponse.results) 26 | } catch (err) { 27 | console.error(err) 28 | } 29 | } 30 | 31 | fetchResults() 32 | }, [searchParams]) 33 | 34 | return [results, setSearchParams] 35 | } 36 | 37 | export default useGiphy 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-workshop", 3 | "private": true, 4 | "description": "A step-by-step workshop for learning React fundamentals.", 5 | "homepage": "https://react-workshop.benmvp.com/", 6 | "license": "GPL-3.0-only", 7 | "engines": { 8 | "node": ">=10", 9 | "npm": ">=6" 10 | }, 11 | "scripts": { 12 | "setup": "scripts/setup", 13 | "start": "react-scripts start", 14 | "build": "react-scripts build", 15 | "test": "react-scripts test --env=jsdom", 16 | "eject": "react-scripts eject" 17 | }, 18 | "dependencies": { 19 | "classnames": "^2.2.6", 20 | "prop-types": "^15.7.2", 21 | "react": "^16.13.1", 22 | "react-dom": "^16.13.1", 23 | "react-scripts": "^3.4.1", 24 | "url-lib": "^3.0.3" 25 | }, 26 | "devDependencies": { 27 | "fs-extra": "^9.0.0", 28 | "prettier": "2.0.1" 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/08-search-focus/Results.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | import ResultsItem from './ResultsItem' 4 | 5 | const Results = ({ items }) => { 6 | const containerEl = useRef(null) 7 | 8 | return ( 9 | items.length > 0 && ( 10 | <> 11 |
12 | {items.map((item) => ( 13 | 21 | ))} 22 |
23 | 34 | 35 | ) 36 | ) 37 | } 38 | 39 | Results.propTypes = { 40 | items: PropTypes.arrayOf( 41 | PropTypes.shape({ 42 | id: PropTypes.string.isRequired, 43 | title: PropTypes.string.isRequired, 44 | url: PropTypes.string.isRequired, 45 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 46 | previewUrl: PropTypes.string.isRequired, 47 | }), 48 | ).isRequired, 49 | } 50 | 51 | export default Results 52 | -------------------------------------------------------------------------------- /src/09-custom-hook/Results.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | import ResultsItem from './ResultsItem' 4 | 5 | const Results = ({ items }) => { 6 | const containerEl = useRef(null) 7 | 8 | return ( 9 | items.length > 0 && ( 10 | <> 11 |
12 | {items.map((item) => ( 13 | 21 | ))} 22 |
23 | 34 | 35 | ) 36 | ) 37 | } 38 | 39 | Results.propTypes = { 40 | items: PropTypes.arrayOf( 41 | PropTypes.shape({ 42 | id: PropTypes.string.isRequired, 43 | title: PropTypes.string.isRequired, 44 | url: PropTypes.string.isRequired, 45 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 46 | previewUrl: PropTypes.string.isRequired, 47 | }), 48 | ).isRequired, 49 | } 50 | 51 | export default Results 52 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 15 | 23 | 24 | 33 | React FUNdamentals Workshop with Ben Ilegbodu 34 | 35 | 36 | 37 | 40 |
41 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/end/Results.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | import ResultsItem from './ResultsItem' 4 | 5 | const Results = ({ items, status }) => { 6 | const containerEl = useRef(null) 7 | const isLoading = status === 'idle' || status === 'pending' 8 | const isRejected = status === 'rejected' 9 | let message 10 | 11 | if (isLoading) { 12 | message = ( 13 |
14 |

Loading new results...

15 |
16 | ) 17 | } else if (isRejected) { 18 | message = ( 19 |
20 |

21 | There was an error retrieving results. Please try again. 22 |

23 |
24 | ) 25 | } 26 | 27 | return ( 28 | <> 29 | {message} 30 | {items.length > 0 && ( 31 | <> 32 |
33 | {items.map((item) => ( 34 | 42 | ))} 43 |
44 | 55 | 56 | )} 57 | 58 | ) 59 | } 60 | 61 | Results.propTypes = { 62 | error: PropTypes.instanceOf(Error), 63 | items: PropTypes.arrayOf( 64 | PropTypes.shape({ 65 | id: PropTypes.string.isRequired, 66 | title: PropTypes.string.isRequired, 67 | url: PropTypes.string.isRequired, 68 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 69 | previewUrl: PropTypes.string.isRequired, 70 | }), 71 | ).isRequired, 72 | status: PropTypes.oneOf(['idle', 'pending', 'resolved', 'rejected']) 73 | .isRequired, 74 | } 75 | 76 | export default Results 77 | -------------------------------------------------------------------------------- /src/10-loading-states/Results.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | import ResultsItem from './ResultsItem' 4 | 5 | const Results = ({ items, status }) => { 6 | const containerEl = useRef(null) 7 | const isLoading = status === 'idle' || status === 'pending' 8 | const isRejected = status === 'rejected' 9 | let message 10 | 11 | if (isLoading) { 12 | message = ( 13 |
14 |

Loading new results...

15 |
16 | ) 17 | } else if (isRejected) { 18 | message = ( 19 |
20 |

21 | There was an error retrieving results. Please try again. 22 |

23 |
24 | ) 25 | } 26 | 27 | return ( 28 | <> 29 | {message} 30 | {items.length > 0 && ( 31 | <> 32 |
33 | {items.map((item) => ( 34 | 42 | ))} 43 |
44 | 55 | 56 | )} 57 | 58 | ) 59 | } 60 | 61 | Results.propTypes = { 62 | error: PropTypes.instanceOf(Error), 63 | items: PropTypes.arrayOf( 64 | PropTypes.shape({ 65 | id: PropTypes.string.isRequired, 66 | title: PropTypes.string.isRequired, 67 | url: PropTypes.string.isRequired, 68 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 69 | previewUrl: PropTypes.string.isRequired, 70 | }), 71 | ).isRequired, 72 | status: PropTypes.oneOf(['idle', 'pending', 'resolved', 'rejected']) 73 | .isRequired, 74 | } 75 | 76 | export default Results 77 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Workshop Code of Conduct 2 | 3 | All attendees, speakers, sponsors and volunteers at this workshop are required to agree with the following code of conduct. Organizers will enforce this code throughout the event. We expect cooperation from all participants to help ensure a safe environment for everybody. 4 | 5 | ## Have questions or need to report an issue? 6 | 7 | Please email Ben Ilegbodu at ben@benmvp.com. 8 | 9 | ## The Quick Version 10 | 11 | Our workshop is dedicated to providing a harassment-free workshop experience for everyone, regardless of gender, gender identity and expression, age, sexual orientation, disability, physical appearance, body size, race, ethnicity, religion (or lack thereof), or technology choices. We do not tolerate harassment of workshop participants in any form. Sexual language and imagery is not appropriate for any workshop venue, including presentations, comments, questions, video chat, Twitter and other online media. Workshop participants violating these rules may be sanctioned or expelled from the workshop _without a refund_ at the discretion of the workshop organizers. 12 | 13 | ## The Less Quick Version 14 | 15 | Harassment includes offensive verbal comments related to gender, gender identity and expression, age, sexual orientation, disability, physical appearance, body size, race, ethnicity, religion, technology choices, sexual images in public spaces, deliberate intimidation, stalking, following, harassing photography or recording, sustained disruption of talks or other events, inappropriate physical contact, and unwelcome sexual attention. 16 | 17 | Participants asked to stop any harassing behavior are expected to comply immediately. 18 | 19 | If a participant engages in harassing behavior, the workshop organizers may take any action they deem appropriate, including warning the offender or expulsion from the workshop _with no refund_. 20 | 21 | If you are being harassed, notice that someone else is being harassed, or have any other concerns, please contact a member of workshop staff immediately. 22 | 23 | Workshop staff will be happy to help participants contact venue security or local law enforcement, provide escorts, or otherwise assist those experiencing harassment to feel safe for the duration of the workshop. We value your attendance. 24 | 25 | We expect participants to follow these rules at workshop and workshop venues and workshop-related social events. 26 | -------------------------------------------------------------------------------- /src/04-lists/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { getResults } from './api' 3 | 4 | const LIMITS = [6, 12, 18, 24, 30] 5 | 6 | const App = () => { 7 | const [inputValue, setInputValue] = useState('') 8 | const [searchLimit, setSearchLimit] = useState(12) 9 | const [results, setResults] = useState([]) 10 | 11 | useEffect(() => { 12 | const fetchResults = async () => { 13 | try { 14 | const apiResponse = await getResults({ 15 | searchQuery: inputValue, 16 | limit: searchLimit, 17 | }) 18 | 19 | setResults(apiResponse.results) 20 | } catch (err) { 21 | console.error(err) 22 | } 23 | } 24 | 25 | fetchResults() 26 | }, [inputValue, searchLimit]) 27 | 28 | return ( 29 |
30 |

Giphy Search!

31 | 32 |
33 | { 38 | setInputValue(e.target.value) 39 | }} 40 | /> 41 | 54 |
55 | 56 | {results.length > 0 && ( 57 |
58 | {results.map((item) => ( 59 |
68 |
78 | ))} 79 |
80 | )} 81 |
82 | ) 83 | } 84 | 85 | export default App 86 | -------------------------------------------------------------------------------- /src/00-begin/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/01-jsx/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/03-api/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/04-lists/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/02-query-field/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/05-form-submit/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/06-components/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/07-prop-types/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/09-custom-hook/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/quiz/README.md: -------------------------------------------------------------------------------- 1 | # Final Quiz! 2 | 3 | Until now, we've only been able to see the first page of results from the Giphy API. We need a pagination UI in order to see more results. 4 | 5 | 🏅 The goal of this final step, the quiz, is to solidify your learning by applying it to build your own pagination component. 6 | 7 |
8 | Help! I didn't finish the previous step! 🚨 9 | 10 | If you didn't successfully complete the previous step, you can jump right in by copying the step. 11 | 12 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them. 13 | 14 | Re-run the setup script, but use the previous step as a starting point: 15 | 16 | ```sh 17 | npm run setup -- src/10-loading-states 18 | ``` 19 | 20 | This will also back up your `src/workshop` folder, saving your work. 21 | 22 | Now restart the app: 23 | 24 | ```sh 25 | npm start 26 | ``` 27 | 28 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below. 29 | 30 |
31 | 32 | ## Exercise 33 | 34 | - Build a `Pagination` component that will allow you to paginate through the giphy results 35 | - `Pagination` has "Previous" & "Next" links/buttons 36 | - Display the `` both above and below the `` 37 | - Use the `offset` property in the call to `getResults()` to request subsequent pages of results 38 | - Use the `total` property in the return value from `getResults()` to calculate how many pages there are 39 | - 🤓 **BONUS:** Disable the "Previous" & "Next" links/buttons when there are no previous/next pages 40 | - 🤓 **BONUS:** Use the Foundation [Pagination](https://get.foundation/sites/docs/pagination.html) as your HTML & CSS to support jumping to specific pages 41 | - Share your `Pagination` component and its use in `App` in a [gist](https://gist.github.com/) on [my AMA](http://www.benmvp.com/ama/) 42 | 43 | ## 🧠 Elaboration & Feedback 44 | 45 | After you're done with the quiz, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Final+Quiz). It will help seal in what you've learned. 46 | 47 | ## 👉🏾 Next Step 48 | 49 | Go to the [End](../end/). 50 | 51 | ## ❓ Questions 52 | 53 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)! 54 | -------------------------------------------------------------------------------- /src/08-search-focus/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | const resp = await fetch( 47 | formatUrl( 48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 49 | { 50 | q: searchQuery, 51 | rating, 52 | limit, 53 | offset, 54 | lang: 'en', 55 | }, 56 | ), 57 | ) 58 | const data = await resp.json() 59 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 60 | id, 61 | title, 62 | url, 63 | rating: rating.toUpperCase(), 64 | previewUrl: images.preview.mp4, 65 | })) 66 | 67 | return { results, total: data.pagination['total_count'] } 68 | } 69 | -------------------------------------------------------------------------------- /src/end/useGiphy.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useReducer } from 'react' 2 | import { getResults } from './api' 3 | 4 | /** 5 | * @typedef {import('./api').SearchParams} SearchParams 6 | * @typedef {import('./api').GiphyResult} GiphyResult 7 | * @typedef {'idle' | 'pending' | 'resolved' | 'rejected'} Status The status of the data 8 | * 9 | * @typedef State 10 | * @property {Status} status 11 | * @property {GiphyResult[]} results 12 | * @property {Error} error 13 | * 14 | * @typedef Action 15 | * @property {'started' | 'success' | 'error'} type 16 | * @property {GiphyResult[]} [results] 17 | * @property {Error} [error] 18 | */ 19 | 20 | /** 21 | * @type {State} 22 | */ 23 | const INITIAL_STATE = { 24 | status: 'idle', 25 | results: [], 26 | error: null, 27 | } 28 | 29 | /** 30 | * Returns an updated version of `state` based on the `action` 31 | * @param {State} state Current state 32 | * @param {Action} action Action to update state 33 | * @returns {State} Updated state 34 | */ 35 | const reducer = (state, action) => { 36 | switch (action.type) { 37 | case 'started': { 38 | return { 39 | ...state, 40 | status: 'pending', 41 | } 42 | } 43 | case 'success': { 44 | return { 45 | ...state, 46 | status: 'resolved', 47 | results: action.results, 48 | } 49 | } 50 | case 'error': { 51 | return { 52 | ...state, 53 | status: 'rejected', 54 | error: action.error, 55 | } 56 | } 57 | default: { 58 | // In case we mis-type an action! 59 | throw new Error(`Unhandled action type: ${action.type}`) 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * @callback SetSearchParams 66 | * @param {SearchParams} searchParams Search parameters 67 | */ 68 | 69 | /** 70 | * A custom hook that returns giphy results and a function to search for updated results 71 | * @returns {[State, SetSearchParams]} 72 | */ 73 | const useGiphy = () => { 74 | const [searchParams, setSearchParams] = useState({}) 75 | const [state, dispatch] = useReducer(reducer, INITIAL_STATE) 76 | 77 | useEffect(() => { 78 | const fetchResults = async () => { 79 | try { 80 | dispatch({ type: 'started' }) 81 | 82 | const apiResponse = await getResults(searchParams) 83 | 84 | dispatch({ type: 'success', results: apiResponse.results }) 85 | } catch (err) { 86 | dispatch({ type: 'error', error: err }) 87 | } 88 | } 89 | 90 | fetchResults() 91 | }, [searchParams]) 92 | 93 | return [state, setSearchParams] 94 | } 95 | 96 | export default useGiphy 97 | -------------------------------------------------------------------------------- /src/10-loading-states/useGiphy.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useReducer } from 'react' 2 | import { getResults } from './api' 3 | 4 | /** 5 | * @typedef {import('./api').SearchParams} SearchParams 6 | * @typedef {import('./api').GiphyResult} GiphyResult 7 | * @typedef {'idle' | 'pending' | 'resolved' | 'rejected'} Status The status of the data 8 | * 9 | * @typedef State 10 | * @property {Status} status 11 | * @property {GiphyResult[]} results 12 | * @property {Error} error 13 | * 14 | * @typedef Action 15 | * @property {'started' | 'success' | 'error'} type 16 | * @property {GiphyResult[]} [results] 17 | * @property {Error} [error] 18 | */ 19 | 20 | /** 21 | * @type {State} 22 | */ 23 | const INITIAL_STATE = { 24 | status: 'idle', 25 | results: [], 26 | error: null, 27 | } 28 | 29 | /** 30 | * Returns an updated version of `state` based on the `action` 31 | * @param {State} state Current state 32 | * @param {Action} action Action to update state 33 | * @returns {State} Updated state 34 | */ 35 | const reducer = (state, action) => { 36 | switch (action.type) { 37 | case 'started': { 38 | return { 39 | ...state, 40 | status: 'pending', 41 | } 42 | } 43 | case 'success': { 44 | return { 45 | ...state, 46 | status: 'resolved', 47 | results: action.results, 48 | } 49 | } 50 | case 'error': { 51 | return { 52 | ...state, 53 | status: 'rejected', 54 | error: action.error, 55 | } 56 | } 57 | default: { 58 | // In case we mis-type an action! 59 | throw new Error(`Unhandled action type: ${action.type}`) 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * @callback SetSearchParams 66 | * @param {SearchParams} searchParams Search parameters 67 | */ 68 | 69 | /** 70 | * A custom hook that returns giphy results and a function to search for updated results 71 | * @returns {[State, SetSearchParams]} 72 | */ 73 | const useGiphy = () => { 74 | const [searchParams, setSearchParams] = useState({}) 75 | const [state, dispatch] = useReducer(reducer, INITIAL_STATE) 76 | 77 | useEffect(() => { 78 | const fetchResults = async () => { 79 | try { 80 | dispatch({ type: 'started' }) 81 | 82 | const apiResponse = await getResults(searchParams) 83 | 84 | dispatch({ type: 'success', results: apiResponse.results }) 85 | } catch (err) { 86 | dispatch({ type: 'error', error: err }) 87 | } 88 | } 89 | 90 | fetchResults() 91 | }, [searchParams]) 92 | 93 | return [state, setSearchParams] 94 | } 95 | 96 | export default useGiphy 97 | -------------------------------------------------------------------------------- /src/end/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | // Fail 1 of 10 times 47 | if (Math.random() * 10 < 1) { 48 | throw new Error('Fake Error!') 49 | } 50 | 51 | const resp = await fetch( 52 | formatUrl( 53 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 54 | { 55 | q: searchQuery, 56 | rating, 57 | limit, 58 | offset, 59 | lang: 'en', 60 | }, 61 | ), 62 | ) 63 | const data = await resp.json() 64 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 65 | id, 66 | title, 67 | url, 68 | rating: rating.toUpperCase(), 69 | previewUrl: images.preview.mp4, 70 | })) 71 | 72 | return { results, total: data.pagination['total_count'] } 73 | } 74 | -------------------------------------------------------------------------------- /src/10-loading-states/api.js: -------------------------------------------------------------------------------- 1 | import { formatUrl } from 'url-lib' 2 | 3 | /** 4 | * Waits the specified amount of time and returns a resolved Promise when done 5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds 6 | * @returns {Promise} Signal that waiting is done 7 | */ 8 | const wait = (waitTimeMs = 0) => { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, waitTimeMs) 11 | }) 12 | } 13 | 14 | /** 15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF 16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF 17 | * 18 | * @typedef GiphyResult 19 | * @type {object} 20 | * @property {string} id The GIF's unique ID 21 | * @property {string} title The title that appears on giphy.com for this GIF 22 | * @property {string} url The unique URL for the GIF 23 | * @property {Rating} rating The MPAA-style rating for the GIF 24 | * @property {string} previewUrl The URL for the GIF in .MP4 format 25 | * 26 | * @typedef SearchParams 27 | * @type {object} 28 | * @property {string} [params.searchQuery=''] Search query term or phrase 29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings 30 | * @property {number} [params.limit=12] The maximum number of images to return 31 | * @property {number} [params.offset=0] Specifies the starting position of the results. 32 | * 33 | * Retrieves a list of giphy image info matching the specified search parameters 34 | * @param {SearchParams} [params] Search parameters 35 | * @returns {{results: GiphyResult[], total: number}} 36 | */ 37 | export const getResults = async ({ 38 | searchQuery = '', 39 | rating = '', 40 | limit = 12, 41 | offset = 0, 42 | } = {}) => { 43 | // Increase the number below to give the appearance of a slow API response 44 | await wait(500) 45 | 46 | // Fail 1 of 10 times 47 | if (Math.random() * 10 < 1) { 48 | throw new Error('Fake Error!') 49 | } 50 | 51 | const resp = await fetch( 52 | formatUrl( 53 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH', 54 | { 55 | q: searchQuery, 56 | rating, 57 | limit, 58 | offset, 59 | lang: 'en', 60 | }, 61 | ), 62 | ) 63 | const data = await resp.json() 64 | const results = data.data.map(({ id, title, url, images, rating }) => ({ 65 | id, 66 | title, 67 | url, 68 | rating: rating.toUpperCase(), 69 | previewUrl: images.preview.mp4, 70 | })) 71 | 72 | return { results, total: data.pagination['total_count'] } 73 | } 74 | -------------------------------------------------------------------------------- /src/06-components/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from 'react' 2 | 3 | const RATINGS = [ 4 | { value: '', label: 'All' }, 5 | { value: 'g', label: 'G' }, 6 | { value: 'pg', label: 'PG' }, 7 | { value: 'pg-13', label: 'PG-13' }, 8 | { value: 'r', label: 'R' }, 9 | ] 10 | const LIMITS = [6, 12, 18, 24, 30] 11 | 12 | const SearchForm = ({ onChange }) => { 13 | const [inputValue, setInputValue] = useState('') 14 | const [searchQuery, setSearchQuery] = useState('') 15 | const [showInstant, setShowInstant] = useState(false) 16 | const [searchRating, setSearchRating] = useState('') 17 | const [searchLimit, setSearchLimit] = useState(12) 18 | const realSearchQuery = showInstant ? inputValue : searchQuery 19 | 20 | useEffect(() => { 21 | onChange({ 22 | searchQuery: realSearchQuery, 23 | rating: searchRating, 24 | limit: searchLimit, 25 | }) 26 | }, [onChange, realSearchQuery, searchRating, searchLimit]) 27 | 28 | const handleSubmit = (e) => { 29 | e.preventDefault() 30 | setSearchQuery(inputValue) 31 | } 32 | 33 | return ( 34 |
35 |
36 | { 41 | setInputValue(e.target.value) 42 | }} 43 | className="input-group-field" 44 | /> 45 | 50 |
51 |
52 | { 58 | setShowInstant(e.target.checked) 59 | }} 60 | /> 61 | 62 |
63 |
64 |
65 | Choose a rating 66 | {RATINGS.map(({ value, label }) => ( 67 | 68 | { 75 | setSearchRating(value) 76 | }} 77 | /> 78 | 79 | 80 | ))} 81 |
82 |
83 | 96 |
97 | ) 98 | } 99 | 100 | export default SearchForm 101 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { spawnSync } = require('child_process') 4 | const { resolve } = require('path') 5 | 6 | const [, , exerciseToCopyPath = 'src/00-begin'] = process.argv 7 | 8 | const CWD = process.cwd() 9 | const WORKSHOP_PATH = resolve(CWD, 'src/workshop') 10 | const EXERCISE_PATH = resolve(CWD, exerciseToCopyPath) 11 | const INDEX_PATH = resolve(CWD, 'src/index.js') 12 | 13 | const COLOR_STYLES = { 14 | blue: { open: '\u001b[34m', close: '\u001b[39m' }, 15 | dim: { open: '\u001b[2m', close: '\u001b[22m' }, 16 | red: { open: '\u001b[31m', close: '\u001b[39m' }, 17 | green: { open: '\u001b[32m', close: '\u001b[39m' }, 18 | } 19 | 20 | const color = (modifier, message) => { 21 | return COLOR_STYLES[modifier].open + message + COLOR_STYLES[modifier].close 22 | } 23 | const blue = (message) => color('blue', message) 24 | const dim = (message) => color('dim', message) 25 | const red = (message) => color('red', message) 26 | const green = (message) => color('green', message) 27 | 28 | const logRunStart = (title, subtitle) => { 29 | console.log(blue(`▶️ Starting: ${title}`)) 30 | console.log(` ${subtitle}`) 31 | } 32 | 33 | const logRunSuccess = (title) => { 34 | console.log(green(`✅ Success: ${title}\n\n`)) 35 | } 36 | 37 | const run = (title, subtitle, command) => { 38 | logRunStart(title, subtitle) 39 | console.log(dim(` Running the following command: ${command}`)) 40 | 41 | const result = spawnSync(command, { stdio: 'inherit', shell: true }) 42 | 43 | if (result.status !== 0) { 44 | console.error( 45 | red( 46 | `🚨 Failure: ${title}. Please review the messages above for information on how to troubleshoot and resolve this issue.`, 47 | ), 48 | ) 49 | process.exit(result.status) 50 | } 51 | 52 | logRunSuccess(title) 53 | } 54 | 55 | const main = async () => { 56 | run( 57 | 'System Validation', 58 | 'Ensuring the correct versions of tools are installed on this computer.', 59 | 'npx check-engine', 60 | ) 61 | 62 | run( 63 | 'Dependency Installation', 64 | 'Installing third party code dependencies so the workshop works properly on this computer.', 65 | 'npm install', 66 | ) 67 | 68 | // Now that the dependencies have been installed we can use `fs-extra` 69 | const { pathExists, move, copy, readFile, writeFile } = require('fs-extra') 70 | 71 | const WORKSHOP_CREATION_TITLE = 'Workshop Folder Creation' 72 | const WORKSHOP_CREATION_SUBTITLE = `Creating workshop directory from ${exerciseToCopyPath}.` 73 | 74 | logRunStart(WORKSHOP_CREATION_TITLE, WORKSHOP_CREATION_SUBTITLE) 75 | 76 | // create a backup of the workshop folder if it exists 77 | if (await pathExists(WORKSHOP_PATH)) { 78 | const now = Date.now() 79 | 80 | console.log( 81 | dim( 82 | ` Workshop folder already exists. Backing up to src/workshop-${now}`, 83 | ), 84 | ) 85 | await move(WORKSHOP_PATH, resolve(`${WORKSHOP_PATH}-${now}`)) 86 | } 87 | 88 | await copy(EXERCISE_PATH, WORKSHOP_PATH) 89 | await writeFile( 90 | INDEX_PATH, 91 | (await readFile(INDEX_PATH, 'utf8')).replace( 92 | /\.\/.*\/App/, 93 | './workshop/App', 94 | ), 95 | ) 96 | 97 | logRunSuccess(WORKSHOP_CREATION_TITLE) 98 | } 99 | 100 | main() 101 | -------------------------------------------------------------------------------- /src/07-prop-types/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const RATINGS = [ 5 | { value: '', label: 'All' }, 6 | { value: 'g', label: 'G' }, 7 | { value: 'pg', label: 'PG' }, 8 | { value: 'pg-13', label: 'PG-13' }, 9 | { value: 'r', label: 'R' }, 10 | ] 11 | const LIMITS = [6, 12, 18, 24, 30] 12 | 13 | const SearchForm = ({ 14 | initialLimit, 15 | initialRating, 16 | initialSearchQuery, 17 | initialShowInstant, 18 | onChange, 19 | }) => { 20 | const [inputValue, setInputValue] = useState(initialSearchQuery) 21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery) 22 | const [showInstant, setShowInstant] = useState(initialShowInstant) 23 | const [searchRating, setSearchRating] = useState(initialRating) 24 | const [searchLimit, setSearchLimit] = useState(initialLimit) 25 | const realSearchQuery = showInstant ? inputValue : searchQuery 26 | 27 | useEffect(() => { 28 | onChange({ 29 | searchQuery: realSearchQuery, 30 | rating: searchRating, 31 | limit: searchLimit, 32 | }) 33 | }, [onChange, realSearchQuery, searchRating, searchLimit]) 34 | 35 | const handleSubmit = (e) => { 36 | e.preventDefault() 37 | setSearchQuery(inputValue) 38 | } 39 | 40 | return ( 41 |
42 |
43 | { 48 | setInputValue(e.target.value) 49 | }} 50 | className="input-group-field" 51 | /> 52 | 57 |
58 |
59 | { 65 | setShowInstant(e.target.checked) 66 | }} 67 | /> 68 | 69 |
70 |
71 |
72 | Choose a rating 73 | {RATINGS.map(({ value, label }) => ( 74 | 75 | { 82 | setSearchRating(value) 83 | }} 84 | /> 85 | 86 | 87 | ))} 88 |
89 |
90 | 103 |
104 | ) 105 | } 106 | 107 | SearchForm.propTypes = { 108 | initialLimit: PropTypes.number, 109 | initialRating: PropTypes.string, 110 | initialSearchQuery: PropTypes.string, 111 | initialShowInstant: PropTypes.bool, 112 | onChange: PropTypes.func.isRequired, 113 | } 114 | SearchForm.defaultProps = { 115 | initialLimit: 12, 116 | initialRating: '', 117 | initialSearchQuery: '', 118 | initialShowInstant: false, 119 | } 120 | 121 | export default SearchForm 122 | -------------------------------------------------------------------------------- /src/end/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect, useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const RATINGS = [ 5 | { value: '', label: 'All' }, 6 | { value: 'g', label: 'G' }, 7 | { value: 'pg', label: 'PG' }, 8 | { value: 'pg-13', label: 'PG-13' }, 9 | { value: 'r', label: 'R' }, 10 | ] 11 | const LIMITS = [6, 12, 18, 24, 30] 12 | 13 | const SearchForm = ({ 14 | initialLimit, 15 | initialRating, 16 | initialSearchQuery, 17 | initialShowInstant, 18 | onChange, 19 | }) => { 20 | const [inputValue, setInputValue] = useState(initialSearchQuery) 21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery) 22 | const [showInstant, setShowInstant] = useState(initialShowInstant) 23 | const [searchRating, setSearchRating] = useState(initialRating) 24 | const [searchLimit, setSearchLimit] = useState(initialLimit) 25 | const realSearchQuery = showInstant ? inputValue : searchQuery 26 | const queryFieldEl = useRef(null) 27 | 28 | useEffect(() => { 29 | onChange({ 30 | searchQuery: realSearchQuery, 31 | rating: searchRating, 32 | limit: searchLimit, 33 | }) 34 | }, [onChange, realSearchQuery, searchRating, searchLimit]) 35 | 36 | const handleSubmit = (e) => { 37 | e.preventDefault() 38 | setSearchQuery(inputValue) 39 | 40 | // focus the query field after submitting 41 | // to make easier to quickly search again 42 | queryFieldEl.current.focus() 43 | } 44 | 45 | return ( 46 |
47 |
48 | { 53 | setInputValue(e.target.value) 54 | }} 55 | className="input-group-field" 56 | ref={queryFieldEl} 57 | /> 58 | 63 |
64 |
65 | { 71 | setShowInstant(e.target.checked) 72 | }} 73 | /> 74 | 75 |
76 |
77 |
78 | Choose a rating 79 | {RATINGS.map(({ value, label }) => ( 80 | 81 | { 88 | setSearchRating(value) 89 | }} 90 | /> 91 | 92 | 93 | ))} 94 |
95 |
96 | 109 |
110 | ) 111 | } 112 | 113 | SearchForm.propTypes = { 114 | initialLimit: PropTypes.number, 115 | initialRating: PropTypes.string, 116 | initialSearchQuery: PropTypes.string, 117 | initialShowInstant: PropTypes.bool, 118 | onChange: PropTypes.func.isRequired, 119 | } 120 | SearchForm.defaultProps = { 121 | initialLimit: 12, 122 | initialRating: '', 123 | initialSearchQuery: '', 124 | initialShowInstant: false, 125 | } 126 | 127 | export default SearchForm 128 | -------------------------------------------------------------------------------- /src/08-search-focus/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect, useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const RATINGS = [ 5 | { value: '', label: 'All' }, 6 | { value: 'g', label: 'G' }, 7 | { value: 'pg', label: 'PG' }, 8 | { value: 'pg-13', label: 'PG-13' }, 9 | { value: 'r', label: 'R' }, 10 | ] 11 | const LIMITS = [6, 12, 18, 24, 30] 12 | 13 | const SearchForm = ({ 14 | initialLimit, 15 | initialRating, 16 | initialSearchQuery, 17 | initialShowInstant, 18 | onChange, 19 | }) => { 20 | const [inputValue, setInputValue] = useState(initialSearchQuery) 21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery) 22 | const [showInstant, setShowInstant] = useState(initialShowInstant) 23 | const [searchRating, setSearchRating] = useState(initialRating) 24 | const [searchLimit, setSearchLimit] = useState(initialLimit) 25 | const realSearchQuery = showInstant ? inputValue : searchQuery 26 | const queryFieldEl = useRef(null) 27 | 28 | useEffect(() => { 29 | onChange({ 30 | searchQuery: realSearchQuery, 31 | rating: searchRating, 32 | limit: searchLimit, 33 | }) 34 | }, [onChange, realSearchQuery, searchRating, searchLimit]) 35 | 36 | const handleSubmit = (e) => { 37 | e.preventDefault() 38 | setSearchQuery(inputValue) 39 | 40 | // focus the query field after submitting 41 | // to make easier to quickly search again 42 | queryFieldEl.current.focus() 43 | } 44 | 45 | return ( 46 |
47 |
48 | { 53 | setInputValue(e.target.value) 54 | }} 55 | className="input-group-field" 56 | ref={queryFieldEl} // ok? 57 | /> 58 | 63 |
64 |
65 | { 71 | setShowInstant(e.target.checked) 72 | }} 73 | /> 74 | 75 |
76 |
77 |
78 | Choose a rating 79 | {RATINGS.map(({ value, label }) => ( 80 | 81 | { 88 | setSearchRating(value) 89 | }} 90 | /> 91 | 92 | 93 | ))} 94 |
95 |
96 | 109 |
110 | ) 111 | } 112 | 113 | SearchForm.propTypes = { 114 | initialLimit: PropTypes.number, 115 | initialRating: PropTypes.string, 116 | initialSearchQuery: PropTypes.string, 117 | initialShowInstant: PropTypes.bool, 118 | onChange: PropTypes.func.isRequired, 119 | } 120 | SearchForm.defaultProps = { 121 | initialLimit: 12, 122 | initialRating: '', 123 | initialSearchQuery: '', 124 | initialShowInstant: false, 125 | } 126 | 127 | export default SearchForm 128 | -------------------------------------------------------------------------------- /src/09-custom-hook/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect, useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const RATINGS = [ 5 | { value: '', label: 'All' }, 6 | { value: 'g', label: 'G' }, 7 | { value: 'pg', label: 'PG' }, 8 | { value: 'pg-13', label: 'PG-13' }, 9 | { value: 'r', label: 'R' }, 10 | ] 11 | const LIMITS = [6, 12, 18, 24, 30] 12 | 13 | const SearchForm = ({ 14 | initialLimit, 15 | initialRating, 16 | initialSearchQuery, 17 | initialShowInstant, 18 | onChange, 19 | }) => { 20 | const [inputValue, setInputValue] = useState(initialSearchQuery) 21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery) 22 | const [showInstant, setShowInstant] = useState(initialShowInstant) 23 | const [searchRating, setSearchRating] = useState(initialRating) 24 | const [searchLimit, setSearchLimit] = useState(initialLimit) 25 | const realSearchQuery = showInstant ? inputValue : searchQuery 26 | const queryFieldEl = useRef(null) 27 | 28 | useEffect(() => { 29 | onChange({ 30 | searchQuery: realSearchQuery, 31 | rating: searchRating, 32 | limit: searchLimit, 33 | }) 34 | }, [onChange, realSearchQuery, searchRating, searchLimit]) 35 | 36 | const handleSubmit = (e) => { 37 | e.preventDefault() 38 | setSearchQuery(inputValue) 39 | 40 | // focus the query field after submitting 41 | // to make easier to quickly search again 42 | queryFieldEl.current.focus() 43 | } 44 | 45 | return ( 46 |
47 |
48 | { 53 | setInputValue(e.target.value) 54 | }} 55 | className="input-group-field" 56 | ref={queryFieldEl} // ok? 57 | /> 58 | 63 |
64 |
65 | { 71 | setShowInstant(e.target.checked) 72 | }} 73 | /> 74 | 75 |
76 |
77 |
78 | Choose a rating 79 | {RATINGS.map(({ value, label }) => ( 80 | 81 | { 88 | setSearchRating(value) 89 | }} 90 | /> 91 | 92 | 93 | ))} 94 |
95 |
96 | 109 |
110 | ) 111 | } 112 | 113 | SearchForm.propTypes = { 114 | initialLimit: PropTypes.number, 115 | initialRating: PropTypes.string, 116 | initialSearchQuery: PropTypes.string, 117 | initialShowInstant: PropTypes.bool, 118 | onChange: PropTypes.func.isRequired, 119 | } 120 | SearchForm.defaultProps = { 121 | initialLimit: 12, 122 | initialRating: '', 123 | initialSearchQuery: '', 124 | initialShowInstant: false, 125 | } 126 | 127 | export default SearchForm 128 | -------------------------------------------------------------------------------- /src/10-loading-states/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect, useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const RATINGS = [ 5 | { value: '', label: 'All' }, 6 | { value: 'g', label: 'G' }, 7 | { value: 'pg', label: 'PG' }, 8 | { value: 'pg-13', label: 'PG-13' }, 9 | { value: 'r', label: 'R' }, 10 | ] 11 | const LIMITS = [6, 12, 18, 24, 30] 12 | 13 | const SearchForm = ({ 14 | initialLimit, 15 | initialRating, 16 | initialSearchQuery, 17 | initialShowInstant, 18 | onChange, 19 | }) => { 20 | const [inputValue, setInputValue] = useState(initialSearchQuery) 21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery) 22 | const [showInstant, setShowInstant] = useState(initialShowInstant) 23 | const [searchRating, setSearchRating] = useState(initialRating) 24 | const [searchLimit, setSearchLimit] = useState(initialLimit) 25 | const realSearchQuery = showInstant ? inputValue : searchQuery 26 | const queryFieldEl = useRef(null) 27 | 28 | useEffect(() => { 29 | onChange({ 30 | searchQuery: realSearchQuery, 31 | rating: searchRating, 32 | limit: searchLimit, 33 | }) 34 | }, [onChange, realSearchQuery, searchRating, searchLimit]) 35 | 36 | const handleSubmit = (e) => { 37 | e.preventDefault() 38 | setSearchQuery(inputValue) 39 | 40 | // focus the query field after submitting 41 | // to make easier to quickly search again 42 | queryFieldEl.current.focus() 43 | } 44 | 45 | return ( 46 |
47 |
48 | { 53 | setInputValue(e.target.value) 54 | }} 55 | className="input-group-field" 56 | ref={queryFieldEl} // ok? 57 | /> 58 | 63 |
64 |
65 | { 71 | setShowInstant(e.target.checked) 72 | }} 73 | /> 74 | 75 |
76 |
77 |
78 | Choose a rating 79 | {RATINGS.map(({ value, label }) => ( 80 | 81 | { 88 | setSearchRating(value) 89 | }} 90 | /> 91 | 92 | 93 | ))} 94 |
95 |
96 | 109 |
110 | ) 111 | } 112 | 113 | SearchForm.propTypes = { 114 | initialLimit: PropTypes.number, 115 | initialRating: PropTypes.string, 116 | initialSearchQuery: PropTypes.string, 117 | initialShowInstant: PropTypes.bool, 118 | onChange: PropTypes.func.isRequired, 119 | } 120 | SearchForm.defaultProps = { 121 | initialLimit: 12, 122 | initialRating: '', 123 | initialSearchQuery: '', 124 | initialShowInstant: false, 125 | } 126 | 127 | export default SearchForm 128 | -------------------------------------------------------------------------------- /src/09-custom-hook/README.md: -------------------------------------------------------------------------------- 1 | # Step 8 - Custom Hook 2 | 3 | We've been able to greatly reduce the scope of the top-level `App` by breaking it down into several components. However, it still directly makes the API call in order to maintain the app-level state. `App` is just an orchestrator, it shouldn't really **do** any work. 4 | 5 | 🏅 The goal of this step is to learn how to create our own custom hooks composed of the base hooks like `useState` & `useEffect`. This allows us to extract component logic into reusable (and testable) functions. 6 | 7 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./). 8 | 9 |
10 | Help! I didn't finish the previous step! 🚨 11 | 12 | If you didn't successfully complete the previous step, you can jump right in by copying the step. 13 | 14 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them. 15 | 16 | Re-run the setup script, but use the previous step as a starting point: 17 | 18 | ```sh 19 | npm run setup -- src/08-search-focus 20 | ``` 21 | 22 | This will also back up your `src/workshop` folder, saving your work. 23 | 24 | Now restart the app: 25 | 26 | ```sh 27 | npm start 28 | ``` 29 | 30 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below. 31 | 32 |
33 | 34 | ## 🐇 Jump Around 35 | 36 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources) 37 | 38 | ## ⭐ Concepts 39 | 40 | - Creating (async) custom hooks 41 | 42 | ## 📝 Tasks 43 | 44 | Create a new file called `src/workshop/useGiphy.js` which will contain our custom hook that will take in search parameters, make an API call and return results: 45 | 46 | ```js 47 | import { useState, useEffect } from 'react' 48 | import { getResults } from './api' // 👈🏾 bring over API import 49 | 50 | const useGiphy = () => { 51 | return null 52 | } 53 | 54 | export default useGiphy 55 | ``` 56 | 57 | A custom hook is a normal JavaScript function whose name **must start** with `use*` and may call other hooks, like `useState` & `useEffect`. 58 | 59 | Copy over all the hooks-related code from `App.js` into `useGiphy.js`: 60 | 61 | ```js 62 | const useGiphy = () => { 63 | const [searchParams, setSearchParams] = useState({}) 64 | const [results, setResults] = useState([]) 65 | 66 | useEffect(() => { 67 | const fetchResults = async () => { 68 | try { 69 | const apiResponse = await getResults(searchParams) 70 | 71 | setResults(apiResponse.results) 72 | } catch (err) { 73 | console.error(err) 74 | } 75 | } 76 | 77 | fetchResults() 78 | }, [searchParams]) 79 | 80 | return [results, setSearchParams] 81 | } 82 | ``` 83 | 84 | Now `useGiphy()` can easily be used w/in other components because all of the state management and side-effect API logic have been abstracted away. 85 | 86 | ## 💡 Exercises 87 | 88 | - Finish the feature by calling `useGiphy()` back in `App` 89 | - Compare the current version of [`App.js`](./App.js) with the [Step 5 `App.js`](../05-form-submit/App.js) 90 | 91 | ## 🧠 Elaboration & Feedback 92 | 93 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+9+-+Custom+Hook). It will help seal in what you've learned. 94 | 95 | ## 👉🏾 Next Step 96 | 97 | Go to [Step 10 - Loading States](../10-loading-states/). 98 | 99 | ## 📕 Resources 100 | 101 | - [Building Your Own Hooks](https://reactjs.org/docs/hooks-custom.html) 102 | - [How to test custom React hooks](https://kentcdodds.com/blog/how-to-test-custom-react-hooks) 103 | - [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html) 104 | 105 | ## ❓ Questions 106 | 107 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)! 108 | -------------------------------------------------------------------------------- /src/07-prop-types/README.md: -------------------------------------------------------------------------------- 1 | # Step 7 - Prop Types 2 | 3 | Components that accept props will define the types of props they accept. This serves two purposes: 4 | 5 | 1. Declare the public API of the component 6 | 2. Validate the props being passed in by the parent 7 | 8 | 🏅 The goal of this step is to learn how to define prop types for a component. 9 | 10 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./). 11 | 12 |
13 | Help! I didn't finish the previous step! 🚨 14 | 15 | If you didn't successfully complete the previous step, you can jump right in by copying the step. 16 | 17 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them. 18 | 19 | Re-run the setup script, but use the previous step as a starting point: 20 | 21 | ```sh 22 | npm run setup -- src/06-components 23 | ``` 24 | 25 | This will also back up your `src/workshop` folder, saving your work. 26 | 27 | Now restart the app: 28 | 29 | ```sh 30 | npm start 31 | ``` 32 | 33 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below. 34 | 35 |
36 | 37 | ## 🐇 Jump Around 38 | 39 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources) 40 | 41 | ## ⭐ Concepts 42 | 43 | - Type-checking props 44 | 45 | ## 📝 Tasks 46 | 47 | Using the [`prop-types`](https://reactjs.org/docs/typechecking-with-proptypes.html) package, add a prop type in `SearchForm` for the `onChange` callback prop: 48 | 49 | ```js 50 | import React, { Fragment, useState, useEffect } from 'react' 51 | import PropTypes from 'prop-types' // 👈🏾 new import 52 | 53 | ... 54 | 55 | const SearchForm = (props) => { 56 | const { onChange } = props 57 | 58 | ... 59 | } 60 | 61 | // define types of props 👇🏾 62 | SearchForm.propTypes = { 63 | onChange: PropTypes.func.isRequired, 64 | } 65 | 66 | export default SearchForm 67 | ``` 68 | 69 | Now add a prop type in `Results` for the `items` prop: 70 | 71 | ```js 72 | import React from 'react' 73 | import PropTypes from 'prop-types' // 👈🏾 new import 74 | 75 | const Results = (props) => { 76 | const { items } = props 77 | 78 | ... 79 | 80 | } 81 | 82 | // define types of props 👇🏾 83 | Results.propTypes = { 84 | items: PropTypes.arrayOf( 85 | PropTypes.shape({ 86 | id: PropTypes.string.isRequired, 87 | title: PropTypes.string.isRequired, 88 | url: PropTypes.string.isRequired, 89 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired, 90 | previewUrl: PropTypes.string.isRequired, 91 | }), 92 | ).isRequired, 93 | } 94 | 95 | export default Results 96 | ``` 97 | 98 | ## 💡 Exercises 99 | 100 | - Add make all of the props for `ResultsItem` required 101 | - Add 4 additional _optional_ props to `SearchForm`: `initialSearchQuery`, `initialShowInstant`, `initialRating` & `initialLimit` 102 | - These will set the initial values of the corresponding state variables (`useState(XXX)`) 103 | - 🔑 _HINT:_ Use [`defaultProps`](https://reactjs.org/docs/typechecking-with-proptypes.html#default-prop-values) to set the default values when the props are not specified 104 | - Add some of the `initial*` props to `` in `App` and use the React Developer Tools to see how the initial UI changes 105 | 106 | ## 🧠 Elaboration & Feedback 107 | 108 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+7+-+Prop+Types). It will help seal in what you've learned. 109 | 110 | ## 👉🏾 Next Step 111 | 112 | Go to [Step 8 - Search Focus](../08-search-focus/). 113 | 114 | ## 📕 Resources 115 | 116 | - [Typechecking with PropTypes](https://reactjs.org/docs/typechecking-with-proptypes.html) 117 | - [Custom Prop Types](https://github.com/airbnb/prop-types) 118 | 119 | ## ❓ Questions 120 | 121 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)! 122 | -------------------------------------------------------------------------------- /src/05-form-submit/App.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from 'react' 2 | import { getResults } from './api' 3 | 4 | const RATINGS = [ 5 | { value: '', label: 'All' }, 6 | { value: 'g', label: 'G' }, 7 | { value: 'pg', label: 'PG' }, 8 | { value: 'pg-13', label: 'PG-13' }, 9 | { value: 'r', label: 'R' }, 10 | ] 11 | const LIMITS = [6, 12, 18, 24, 30] 12 | 13 | const App = () => { 14 | const [inputValue, setInputValue] = useState('') 15 | const [searchQuery, setSearchQuery] = useState('') 16 | const [showInstant, setShowInstant] = useState(false) 17 | const [searchRating, setSearchRating] = useState('') 18 | const [searchLimit, setSearchLimit] = useState(12) 19 | const realSearchQuery = showInstant ? inputValue : searchQuery 20 | const [results, setResults] = useState([]) 21 | 22 | useEffect(() => { 23 | const fetchResults = async () => { 24 | try { 25 | const apiResponse = await getResults({ 26 | searchQuery: realSearchQuery, 27 | limit: searchLimit, 28 | rating: searchRating, 29 | }) 30 | 31 | setResults(apiResponse.results) 32 | } catch (err) { 33 | console.error(err) 34 | } 35 | } 36 | 37 | fetchResults() 38 | }, [realSearchQuery, searchRating, searchLimit]) 39 | 40 | const handleSubmit = (e) => { 41 | e.preventDefault() 42 | setSearchQuery(inputValue) 43 | } 44 | 45 | return ( 46 |
47 |

Giphy Search!

48 | 49 |
50 |
51 | { 56 | setInputValue(e.target.value) 57 | }} 58 | className="input-group-field" 59 | /> 60 | 65 |
66 |
67 | { 73 | setShowInstant(e.target.checked) 74 | }} 75 | /> 76 | 77 |
78 |
79 |
80 | Choose a rating 81 | {RATINGS.map(({ value, label }) => ( 82 | 83 | { 90 | setSearchRating(value) 91 | }} 92 | /> 93 | 94 | 95 | ))} 96 |
97 |
98 | 111 |
112 | 113 | {results.length > 0 && ( 114 |
115 | {results.map((item) => ( 116 |
125 |
135 | ))} 136 |
137 | )} 138 |
139 | ) 140 | } 141 | 142 | export default App 143 | -------------------------------------------------------------------------------- /src/08-search-focus/README.md: -------------------------------------------------------------------------------- 1 | # Step 8 - Search Focus 2 | 3 | When we submit the search form by clicking the "Search" button, the search button now becomes the focused element. But we want the focus to go back to the query field so that we can easily type a new search. 4 | 5 | By default, when we develop in React, we're never touching any actual DOM. Instead we're rendering UI that React efficiently applies to the DOM. But there are rare times where we'll need to interact with the DOM directly in order to mutate it in ways that React doesn't support. 6 | 7 | 🏅 So the goal of this step is to use this "escape hatch" in order to focus the search query imperatively. 8 | 9 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./). 10 | 11 |
12 | Help! I didn't finish the previous step! 🚨 13 | 14 | If you didn't successfully complete the previous step, you can jump right in by copying the step. 15 | 16 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them. 17 | 18 | Re-run the setup script, but use the previous step as a starting point: 19 | 20 | ```sh 21 | npm run setup -- src/07-prop-types 22 | ``` 23 | 24 | This will also back up your `src/workshop` folder, saving your work. 25 | 26 | Now restart the app: 27 | 28 | ```sh 29 | npm start 30 | ``` 31 | 32 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below. 33 | 34 |
35 | 36 | ## 🐇 Jump Around 37 | 38 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources) 39 | 40 | ## ⭐ Concepts 41 | 42 | - Interacting with the DOM directly with `useRef` hook 43 | 44 | ## 📝 Tasks 45 | 46 | In [`SearchForm.js`](./SearchForm.js), Import the `useRef` hook from `react`: 47 | 48 | ```js 49 | import React, { Fragment, useState, useEffect, useRef } from 'react' // 👈🏾 new import 50 | ``` 51 | 52 | Create a ref within `SearchForm` after all of the state variables: 53 | 54 | ```js 55 | const [inputValue, setInputValue] = useState(initialSearchQuery) 56 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery) 57 | const [showInstant, setShowInstant] = useState(initialShowInstant) 58 | const [searchRating, setSearchRating] = useState(initialRating) 59 | const [searchLimit, setSearchLimit] = useState(initialLimit) 60 | const realSearchQuery = showInstant ? inputValue : searchQuery 61 | 62 | // new ref 👇🏾 63 | const queryFieldEl = useRef(null) 64 | ``` 65 | 66 | Add the ref as the `ref` prop to the query field: 67 | 68 | ```js 69 | { 74 | setInputValue(e.target.value) 75 | }} 76 | className="input-group-field" 77 | ref={queryFieldEl} // 👈🏾 ref is here 78 | /> 79 | ``` 80 | 81 | Back in `handleSubmit`, focus the field on submission of the form by accessing `queryFieldEl.current`: 82 | 83 | ```js 84 | const handleSubmit = (e) => { 85 | e.preventDefault() 86 | setSearchQuery(inputValue) 87 | 88 | // focus the query field after submitting 89 | // to make easier to quickly search again 90 | // 👇🏾 91 | queryFieldEl.current.focus() 92 | } 93 | ``` 94 | 95 | Now when we submit the field with the "Search" button the query field goes back to being focused, just like if we submitted by pressing ENTER in the field. 96 | 97 | ## 💡 Exercises 98 | 99 | - Add a "To top" button at the bottom of the results that when clicked jumps the user to the top of the results 100 | - 🔑 _HINT:_ [`element.scrollIntoView(true)`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) will align the top of element in the window 101 | - 🤓 **BONUS:** Animate the scrolling so it's smooth instead of a jump 102 | 103 | ## 🧠 Elaboration & Feedback 104 | 105 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+8+-+Search+Focus). It will help seal in what you've learned. 106 | 107 | ## 👉🏾 Next Step 108 | 109 | Go to [Step 9 - Custom Hook](../09-custom-hook/). 110 | 111 | ## 📕 Resources 112 | 113 | - [`useRef` API Reference](https://reactjs.org/docs/hooks-reference.html#useref) 114 | - [Introduction to useRef Hook](https://dev.to/dinhhuyams/introduction-to-useref-hook-3m7n) 115 | - [Using `useRef` as an "instance variable"](https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables) 116 | 117 | ## ❓ Questions 118 | 119 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)! 120 | -------------------------------------------------------------------------------- /src/02-query-field/README.md: -------------------------------------------------------------------------------- 1 | # Step 2 - Query Field 2 | 3 | The goal of this step is learning how to deal with forms. HTML form elements work a little bit differently from other DOM elements in React, because form elements naturally keep some internal state. Regular HTML forms _do_ work in React, but in most cases, it's convenient to have React keep track of the input that the user has entered into a form. The standard way to achieve this is with a technique called ["controlled components"](https://reactjs.org/docs/forms.html#controlled-components). 4 | 5 | [Handling events](https://reactjs.org/docs/handling-events.html) within React elements is very similar to handling events on DOM elements. Event handlers will be passed instances of [`SyntheticEvent`](https://reactjs.org/docs/events.html), a cross-browser wrapper around the browser's native event. It has the same interface as the browser's native event (including`stopPropagation()` and `preventDefault()`), except the events work identically across all browsers! 6 | 7 | 🏅 Ultimately, the goal of this step is to keep the current value of the search query field in UI state. 8 | 9 | If you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./). 10 | 11 |
12 | Help! I didn't finish the previous step! 🚨 13 | 14 | If you didn't successfully complete the previous step, you can jump right in by copying the step. 15 | 16 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them. 17 | 18 | Re-run the setup script, but use the previous step as a starting point: 19 | 20 | ```sh 21 | npm run setup -- src/01-jsx 22 | ``` 23 | 24 | This will also back up your `src/workshop` folder, saving your work. 25 | 26 | Now restart the app: 27 | 28 | ```sh 29 | npm start 30 | ``` 31 | 32 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below. 33 | 34 |
35 | 36 | ## 🐇 Jump Around 37 | 38 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources) 39 | 40 | ## ⭐ Concepts 41 | 42 | - Maintaining UI state with the `useState` hook 43 | - Handling user interaction 44 | - Handling HTML form elements 45 | 46 | ## 📝 Tasks 47 | 48 | Add a search form with a query field: 49 | 50 | ```js 51 | const App = () => { 52 | return ( 53 |
54 |

Giphy Search!

55 | 56 |
57 | 58 |
59 |
60 | ) 61 | } 62 | ``` 63 | 64 | As of now, the DOM is maintaining the state of the input fields; React has no idea what the values of the fields are. They are currently ["uncontrolled components"](https://reactjs.org/docs/uncontrolled-components.html). We want to make them "controlled components" so we can keep track of their state within the app. 65 | 66 | Using the [`useState` hook](https://reactjs.org/docs/hooks-state.html), add a new state variable for the query field and pass it as the `value` of the ``. Then for `onChange`, update the state. 67 | 68 | ```js 69 | import React, { useState } from 'react' // 👈🏾 import `useState` 70 | 71 | const App = () => { 72 | // new state variable 👇🏾 73 | const [inputValue, setInputValue] = useState('') 74 | 75 | return ( 76 |
77 |

Giphy Search!

78 | 79 |
80 | { 85 | // 👆🏾 pass event handler 86 | setInputValue(e.target.value) 87 | }} 88 | /> 89 |
90 |
91 | ) 92 | } 93 | ``` 94 | 95 | > NOTE: Be sure to import `useState` from the `react` package at the top. 96 | 97 | ## 💡 Exercises 98 | 99 | - Use the React Developer Tools to watch the `state` of `App` update as you type into the fields 100 | - Add a `

` below that will display "You are typing **[inputValue]** in the field." (with the displayed value in **bold**) 101 | - 🤓 **BONUS:** Add a button that when clicked will toggle the text in the `

` between being upper-cased and not 102 | - 🔑 _HINT:_ You will need to add a second `useState` 103 | 104 | ## 🧠 Elaboration & Feedback 105 | 106 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+2+-+Query+Field). It will help seal in what you've learned. 107 | 108 | ## 👉🏾 Next Step 109 | 110 | Go to [Step 3 - API](../03-api/). 111 | 112 | ## 📕 Resources 113 | 114 | - [Using the State Hook](https://reactjs.org/docs/hooks-state.html) 115 | - [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html) 116 | - [Why React hooks?](https://tylermcginnis.com/why-react-hooks/) 117 | - [Handling Events](https://reactjs.org/docs/handling-events.html) 118 | - [Lifting State Up](https://reactjs.org/docs/lifting-state-up.html) 119 | - [`SyntheticEvent`](https://reactjs.org/docs/events.html) 120 | - [Forms](https://reactjs.org/docs/forms.html) 121 | - [DOM Elements](https://reactjs.org/docs/dom-elements.html) 122 | 123 | ## ❓ Questions 124 | 125 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)! 126 | -------------------------------------------------------------------------------- /src/03-api/README.md: -------------------------------------------------------------------------------- 1 | # Step 3 - API 2 | 3 | 🏅 The goal of this step is to retrieve a list of giphy images based on the query typed in the query input field from [Step 2](../02-query-field). We'll do this by using the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and [ES6 Promises](http://www.benmvp.com/learning-es6-promises/) to retrieve the data from the [Giphy API](https://developers.giphy.com/docs/api/endpoint/), and then store the data in app state. 4 | 5 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./). 6 | 7 |

8 | Help! I didn't finish the previous step! 🚨 9 | 10 | If you didn't successfully complete the previous step, you can jump right in by copying the step. 11 | 12 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them. 13 | 14 | Re-run the setup script, but use the previous step as a starting point: 15 | 16 | ```sh 17 | npm run setup -- src/02-query-field 18 | ``` 19 | 20 | This will also back up your `src/workshop` folder, saving your work. 21 | 22 | Now restart the app: 23 | 24 | ```sh 25 | npm start 26 | ``` 27 | 28 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below. 29 | 30 |
31 | 32 | ## 🐇 Jump Around 33 | 34 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources) 35 | 36 | ## ⭐ Concepts 37 | 38 | - Making API calls with the `useEffect` hook 39 | - Using Promises & `async`/`await` 40 | - Maintaining app state with the `useState` hook 41 | 42 | ## 📝 Tasks 43 | 44 | Import the `getResults` API helper along with `useEffect` from React. Then call `getResults()` within `useEffect()`, passing in the value of the input field: 45 | 46 | ```js 47 | import React, { useState, useEffect } from 'react' 48 | import { getResults } from './api' 49 | // 👆🏾 new imports 50 | 51 | const App = () => { 52 | const [inputValue, setInputValue] = useState('') 53 | 54 | // 👇🏾 call API w/ useEffect 55 | useEffect(() => { 56 | getResults({ searchQuery: inputValue }) 57 | }, [inputValue]) 58 | 59 | return ( 60 |
61 |

Giphy Search!

62 | 63 |
64 | { 69 | setInputValue(e.target.value) 70 | }} 71 | /> 72 |
73 |
74 | ) 75 | } 76 | 77 | export default App 78 | ``` 79 | 80 | > NOTE: Be sure to import `useEffect` from the `react` package. 81 | 82 | Check the Network panel of your Developer Tools to see that it is making an API call for every character typed within the query input field. The path to interactivity has begun. 83 | 84 | In order to render the giphy images we need to store the results in state, once again leveraging `useState`: 85 | 86 | ```js 87 | const [inputValue, setInputValue] = useState('') 88 | const [results, setResults] = useState([]) 89 | // 👆🏾 new state variable to contain the search results 90 | 91 | useEffect(() => { 92 | // resolve the promise to get the results 👇🏾 93 | getResults({ searchQuery: inputValue }).then((apiResponse) => 94 | setResults(apiResponse.results), 95 | ) 96 | }, [inputValue]) 97 | 98 | // 👇🏾 logging the results for now 99 | console.log({ inputValue, results }) 100 | ``` 101 | 102 | If you prefer to use [`async` functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) over Promises, you can do that too: 103 | 104 | ```js 105 | useEffect(() => { 106 | const fetchResults = async () => { 107 | // add async 👆🏾 108 | try { 109 | // 👆🏾 try above, 👇🏾 await below 110 | const apiResponse = await getResults({ searchQuery: inputValue }) 111 | 112 | setResults(apiResponse.results) 113 | } catch (err) { 114 | console.error(err) 115 | } 116 | } 117 | 118 | fetchResults() 119 | }, [inputValue]) 120 | 121 | console.log({ inputValue, results }) 122 | ``` 123 | 124 | ## 💡 Exercises 125 | 126 | - Type in different search queries and verify the results by digging into the log and navigating to URLs 127 | - Take a look at [`api.js`](./api.js) and see what the API helper does, particularly the other search filters it supports 128 | - Pass in hard-coded values for the other search filters to `getResults()` and see how the logged data changes 129 | 130 | ## 🧠 Elaboration & Feedback 131 | 132 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+3+-+API). It will help seal in what you've learned. 133 | 134 | ## 👉🏾 Next Step 135 | 136 | Go to [Step 4 - Lists](../04-lists/). 137 | 138 | ## 📕 Resources 139 | 140 | - [Using the Effect Hook](https://reactjs.org/docs/hooks-effect.html) 141 | - [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) & Github's [`fetch` polyfill](https://github.com/github/fetch) 142 | - [Learning ES6: Promises](http://www.benmvp.com/learning-es6-promises/) 143 | - [Async functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) 144 | - [HTTP Methods](http://restfulapi.net/http-methods/) 145 | - [Postman](https://www.getpostman.com/) 146 | 147 | ## ❓ Questions 148 | 149 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)! 150 | -------------------------------------------------------------------------------- /src/04-lists/README.md: -------------------------------------------------------------------------------- 1 | # Step 4 - Lists 2 | 3 | 🏅 The goal of this step is to practice transforming lists of data into lists of components which can be included in JSX. We'll take the `results` data we have in state in convert it to visible UI! 4 | 5 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./). 6 | 7 |
8 | Help! I didn't finish the previous step! 🚨 9 | 10 | If you didn't successfully complete the previous step, you can jump right in by copying the step. 11 | 12 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them. 13 | 14 | Re-run the setup script, but use the previous step as a starting point: 15 | 16 | ```sh 17 | npm run setup -- src/03-api 18 | ``` 19 | 20 | This will also back up your `src/workshop` folder, saving your work. 21 | 22 | Now restart the app: 23 | 24 | ```sh 25 | npm start 26 | ``` 27 | 28 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below. 29 | 30 |
31 | 32 | ## 🐇 Jump Around 33 | 34 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources) 35 | 36 | ## ⭐ Concepts 37 | 38 | - Rendering dynamic lists of data 39 | - Handling special `key` prop 40 | - Conditionally rendering components 41 | 42 | ## 📝 Tasks 43 | 44 | We need to convert the array of results into an array of components so that we can render the giphy images. There are several ways, but the most common approach is to use [`Array.prototype.map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map): 45 | 46 | ```js 47 | return ( 48 |
49 |

Giphy Search!

50 | 51 |
52 | { 57 | setInputValue(e.target.value) 58 | }} 59 | /> 60 |
61 | 62 |
63 | {results.map((item) => ( 64 |
73 |
74 | ) 75 | ``` 76 | 77 | > NOTE: Be sure to include the [`key` prop](https://reactjs.org/docs/lists-and-keys.html) on the `
  • ` elements. 78 | 79 | We need to only render the `
    ` when there are results. There are a number of different ways to [conditionally render JSX](https://reactjs.org/docs/conditional-rendering.html). The most common approach is: 80 | 81 | ```js 82 | return ( 83 |
    84 |

    Giphy Search!

    85 | 86 |
    87 | { 92 | setInputValue(e.target.value) 93 | }} 94 | /> 95 |
    96 | 97 | {results.length > 0 && ( // 👈🏾 inline if with && 98 |
    99 | {results.map((item) => ( 100 |
    109 | )} 110 |
    111 | ) 112 | ``` 113 | 114 | Let's add some additional markup and classes around the `` so we can include the giphy title: 115 | 116 | ```js 117 | { 118 | results.length > 0 && ( 119 |
    120 | {results.map((item) => ( 121 |
    130 |
    135 | ))} 136 |
    137 | ) 138 | } 139 | ``` 140 | 141 | > NOTE: Be sure to move the `key` prop to the containing `
    ` within the `.map()`. 142 | 143 | ## 💡 Exercises 144 | 145 | - Make the displayed title (`item.title`) link to the Giphy URL (`item.url`) 146 | - Display the Giphy Rating (`item.rating`) 147 | - Add a `` 149 | - The props should be ` 68 | 69 | 70 | ) 71 | } 72 | ``` 73 | 74 | Try adding classes to JSX markup, or a `