├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── components │ └── App.js ├── index.css ├── index.js └── utils │ ├── _DATA.js │ ├── api.js │ └── helpers.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Redux Twitter (Curriculum) 4 | ======== 5 | 6 | #### This is the curriculum for TylerMcGinnis.com's [Redux course](https://tylermcginnis.com/courses/redux/) 7 | 8 | ## Objective 9 | Build a functioning Twitter like application. You can play around with the [final app here](https://ui-twitter.netlify.com/). 10 | 11 | ## Notes 12 | The goal here is to give you just enough guidance for you to struggle without drowning. Note that the steps below are just suggestions. The ideal situation is you look at the completed project, then you build it. However, if you're not up for such things, feel free to follow the (vague by design) steps below. If you get stuck, all steps have coinciding branches for you to reference as a last case scenario. 13 | 14 | ## Step 0: Examine the Final Product 15 | * Head over [HERE](https://ui-twitter.netlify.com/) and play around with the final project. Think about how you'd structure not only your UI, but also your Redux store. 16 | 17 | ## Step 1: Download the starter code 18 | Just like we did in the Polls app, we're going to "mock" the database/server. 19 | 20 | * Clone this repositoty 21 | * Check out the given code 22 | * Get comfortable with the exports in `api.js` and `helpers.js`. 23 | * You'll use the `formatTweet` method as a format step between the structure of a tweet in the "database" and the structure of a Tweet in your Redux store. 24 | 25 | ## Step 2: First Actions 26 | The first step you'll need to do in order to start building out your app is to fetch the initial data your app needs to render the home view. Before you can do that, you'll need to set up the actions and action creators responsible for doing that. 27 | 28 | * Create an action creator responsible for setting the authenticated user (eventually set it to `tylermcginnis`, `sarah_edo`, or `dan_abramov`). 29 | * Create an action creator you'll invoke after you receive the tweets from your API request. 30 | * Create an action creator you'll invoke after you receive the users from your API request. 31 | * Create an async action creator responsible for fetching the initial data of your app, then invoking your other action creators passing it that data. 32 | 33 | ## Step 3: First Reducers 34 | 35 | * Create the reducers which are going to update the store based on the action creators you created in the last step. 36 | * Create a store and use react-redux's Provider component to put it on context. 37 | 38 | ## Step 4: Middleware 39 | In order for your async action creators (thunks) to work, you'll need to enable them as middleware. 40 | 41 | * Build a logger middleware. 42 | * Enable your logger middleware and the redux-thunk middleware in your store. 43 | 44 | ## Step 5: Initial Data 45 | Now that you have your action creators, reducers, and middleware set up, now you need to actually fetch your initial data. 46 | 47 | * Invoke your action creator which handles fetching the initial data. 48 | 49 | At this point, you should have no UI but you should have an authed user, the users, and the tweets in your store. 50 | 51 | ## Step 6: Dashboard 52 | Now is time to start working on the UI 53 | 54 | * Create a new component that receives all the Tweet Ids from the store and renders them. 55 | * Render your new component in the main App.js file only once the data has been loaded. 56 | 57 | 58 | 59 | ## Step 7: Tweet UI 60 | Now instead of just rendering IDs, you want to render the full Tweet UI. 61 | 62 | * Build a Tweet component that takes in an ID, grabs that tweet (using the ID) from the store, and renders whatever UI you'd like. 63 | 64 | 65 | 66 | ## Step 8: Loading 67 | You don't just want to show a blank UI as your app loads. 68 | 69 | * Download the `react-redux-loading` library. 70 | * Hook up the loading state to your reducer and show a loading indicator as your app is loading. 71 | 72 | ## Step 9: Like Tweet 73 | Now that the UI is set up for liking a Tweet, you need to build out that functionality. 74 | 75 | * Build out the proper action creators you'll dispatch when a user likes a tweet. 76 | * Update your tweets reducer to handle the new actions you just created. 77 | * Dispatch your new action creator when a Tweet is liked. 78 | 79 | ## Step 10: Compose Tweet UI 80 | Now we want to be able to add a new Tweet. 81 | 82 | * Create the UI for a component which allows the user to input a new Tweet. 83 | * Render that component. 84 | 85 | 86 | 87 | ## Step 11: Compose Tweet Logic 88 | Now that you have the UI for creating a new Tweet, the next step is adding in the logic. 89 | 90 | * Create your actions for handling adding a new Tweet. Remember the `saveTweet` method from your API. 91 | * Update your tweets reducer to handle the new actions you just built. 92 | * Dispatch your new action creator when a new Tweet is created. 93 | 94 | ## Step 12 : Tweet Page 95 | Now the only last view in our app we need is the individual Tweet page. 96 | 97 | * Create a new component which renders the proper UI in the image below. Remember you'll need to render any replies to that Tweet as well. 98 | 99 | Eventually you'll use React Router to render this route, for now, render it like this and you'll be able to grab `match.params.id` for the Tweet id just as React Router will give you. 100 | 101 | ```js 102 | 103 | ``` 104 | 105 | 106 | 107 | ## Step 13: React Router 108 | Now, the last step is to dynamically render UI based on the URL. You can use React Router to do this. 109 | 110 | * install `react-router-dom` 111 | * Create a navbar you can render to navigate between views. 112 | * Add in `Route`s so you only render certain components on certain paths. 113 | * Redirect to `/` after the user composes a new Tweet. 114 | * Navigate to the individual Tweet page when a user clicks on a Tweet. 115 | 116 | [Tyler](https://twitter.com/tylermcginnis) 117 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-twitter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.2.0", 7 | "react-dom": "^16.2.0", 8 | "react-scripts": "1.1.1" 9 | }, 10 | "scripts": { 11 | "start": "react-scripts start", 12 | "build": "react-scripts build", 13 | "test": "react-scripts test --env=jsdom", 14 | "eject": "react-scripts eject" 15 | } 16 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tylermcginnis/redux-course-curriculum/9129ddbb6ce9633f12833968c1a619cc0eee5a30/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class App extends Component { 4 | render() { 5 | return ( 6 |
7 | Start Code 8 |
9 | ) 10 | } 11 | } 12 | 13 | export default App -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './components/App' 5 | 6 | ReactDOM.render(, document.getElementById('root')) -------------------------------------------------------------------------------- /src/utils/_DATA.js: -------------------------------------------------------------------------------- 1 | let users = { 2 | sarah_edo: { 3 | id: "sarah_edo", 4 | name: "Sarah Drasner", 5 | avatarURL: "https://tylermcginnis.com/would-you-rather/sarah.jpg", 6 | tweets: ['8xf0y6ziyjabvozdd253nd', 'hbsc73kzqi75rg7v1e0i6a', '2mb6re13q842wu8n106bhk', '6h5ims9iks66d4m7kqizmv', '3sklxkf9yyfowrf0o1ftbb'], 7 | }, 8 | tylermcginnis: { 9 | id: "tylermcginnis", 10 | name: "Tyler McGinnis", 11 | avatarURL: "https://tylermcginnis.com/would-you-rather/tyler.jpg", 12 | tweets: ['5c9qojr2d1738zlx09afby', 'f4xzgapq7mu783k9t02ghx', 'nnvkjqoevs8t02lzcc0ky', '4pt0px8l0l9g6y69ylivti', 'fap8sdxppna8oabnxljzcv', 'leqp4lzfox7cqvsgdj0e7', '26p5pskqi88i58qmza2gid', 'xi3ca2jcfvpa0i3t4m7ag'], 13 | }, 14 | dan_abramov: { 15 | id: "dan_abramov", 16 | name: "Dan Abramov", 17 | avatarURL: "https://tylermcginnis.com/would-you-rather/dan.jpg", 18 | tweets: ['5w6k1n34dkp1x29cuzn2zn', 'czpa59mg577x1oo45cup0d', 'omdbjl68fxact38hk7ypy6', '3km0v4hf1ps92ajf4z2ytg', 'njv20mq7jsxa6bgsqc97', 'sfljgka8pfddbcer8nuxv', 'r0xu2v1qrxa6ygtvf2rkjw'], 19 | } 20 | } 21 | 22 | let tweets = { 23 | "8xf0y6ziyjabvozdd253nd": { 24 | id: "8xf0y6ziyjabvozdd253nd", 25 | text: "Shoutout to all the speakers I know for whom English is not a first language, but can STILL explain a concept well. It's hard enough to give a good talk in your mother tongue!", 26 | author: "sarah_edo", 27 | timestamp: 1518122597860, 28 | likes: ['tylermcginnis'], 29 | replies: ['fap8sdxppna8oabnxljzcv', '3km0v4hf1ps92ajf4z2ytg'], 30 | replyingTo: null, 31 | }, 32 | "5c9qojr2d1738zlx09afby": { 33 | id: "5c9qojr2d1738zlx09afby", 34 | text: "I hope one day the propTypes pendulum swings back. Such a simple yet effective API. Was one of my favorite parts of React.", 35 | author: "tylermcginnis", 36 | timestamp: 1518043995650, 37 | likes: ['sarah_edo', 'dan_abramov'], 38 | replies: ['njv20mq7jsxa6bgsqc97'], 39 | replyingTo: null, 40 | }, 41 | "f4xzgapq7mu783k9t02ghx": { 42 | id: "f4xzgapq7mu783k9t02ghx", 43 | text: "Want to work at Facebook/Google/:BigCompany? Start contributing code long before you ever interview there.", 44 | author: "tylermcginnis", 45 | timestamp: 1517043995650, 46 | likes: ['dan_abramov'], 47 | replies: [], 48 | replyingTo: null, 49 | }, 50 | "hbsc73kzqi75rg7v1e0i6a": { 51 | id: "hbsc73kzqi75rg7v1e0i6a", 52 | text: "Puppies 101: buy a hamper with a lid on it.", 53 | author: "sarah_edo", 54 | timestamp: 1516043995650, 55 | likes: ['tylermcginnis'], 56 | replies: ['leqp4lzfox7cqvsgdj0e7', 'sfljgka8pfddbcer8nuxv'], 57 | replyingTo: null, 58 | }, 59 | "5w6k1n34dkp1x29cuzn2zn": { 60 | id: "5w6k1n34dkp1x29cuzn2zn", 61 | text: "Is there a metric like code coverage, but that shows lines that, if changed (in a syntactically correct way), wouldn’t cause tests to fail?", 62 | author: "dan_abramov", 63 | timestamp: 1515043995650, 64 | likes: ['sarah_edo'], 65 | replies: [], 66 | replyingTo: null, 67 | }, 68 | "czpa59mg577x1oo45cup0d": { 69 | id: "czpa59mg577x1oo45cup0d", 70 | text: "React came out 'rethinking best practices'. It has since accumulated 'best practices' of its own. Let’s see if we can do better.", 71 | author: "dan_abramov", 72 | timestamp: 1515043995650, 73 | likes: ['tylermcginnis', 'sarah_edo'], 74 | replies: ['3sklxkf9yyfowrf0o1ftbb'], 75 | replyingTo: null, 76 | }, 77 | "2mb6re13q842wu8n106bhk": { 78 | id: "2mb6re13q842wu8n106bhk", 79 | text: "I think I realized I like dogs so much because I can really relate to being motivated by snacks", 80 | author: "sarah_edo", 81 | timestamp: 1514043995650, 82 | likes: ['dan_abramov'], 83 | replies: ['26p5pskqi88i58qmza2gid'], 84 | replyingTo: null, 85 | }, 86 | "nnvkjqoevs8t02lzcc0ky": { 87 | id: "nnvkjqoevs8t02lzcc0ky", 88 | text: "Maybe the real benefit of open source was the friendships we made along the way?", 89 | author: "tylermcginnis", 90 | timestamp: 1513043995650, 91 | likes: [], 92 | replies: [], 93 | replyingTo: null, 94 | }, 95 | "omdbjl68fxact38hk7ypy6": { 96 | id: "omdbjl68fxact38hk7ypy6", 97 | text: "A 7-minute Paul Joseph Watson video being translated and aired by a Russian state TV channel is the most surreal thing I’ve seen in 2018 yet", 98 | author: "dan_abramov", 99 | timestamp: 1512043995650, 100 | likes: [], 101 | replies: [], 102 | replyingTo: null, 103 | }, 104 | "4pt0px8l0l9g6y69ylivti": { 105 | id: "4pt0px8l0l9g6y69ylivti", 106 | text: "Talking less about the downsides of OSS and focusing on some of the huge potential upsides for once might just help get more people into it.", 107 | author: "tylermcginnis", 108 | timestamp: 1511043995650, 109 | likes: ['dan_abramov'], 110 | replies: [], 111 | replyingTo: null, 112 | }, 113 | "6h5ims9iks66d4m7kqizmv": { 114 | id: "6h5ims9iks66d4m7kqizmv", 115 | text: "By the way, if you have a blog post sitting around and want to get some eyes on it, we take guest submissions! That's how I started.", 116 | author: "sarah_edo", 117 | timestamp: 1510043995650, 118 | likes: ['dan_abramov', 'tylermcginnis'], 119 | replies: ['xi3ca2jcfvpa0i3t4m7ag', 'r0xu2v1qrxa6ygtvf2rkjw'], 120 | replyingTo: null, 121 | }, 122 | "fap8sdxppna8oabnxljzcv": { 123 | id: "fap8sdxppna8oabnxljzcv", 124 | author: "tylermcginnis", 125 | text: "I agree. I'm always really impressed when I see someone giving a talk in a language that's not their own.", 126 | timestamp: 1518122677860, 127 | likes: ['sarah_edo'], 128 | replyingTo: "8xf0y6ziyjabvozdd253nd", 129 | replies: [], 130 | }, 131 | "3km0v4hf1ps92ajf4z2ytg": { 132 | id: "3km0v4hf1ps92ajf4z2ytg", 133 | author: "dan_abramov", 134 | text: "It can be difficult at times.", 135 | timestamp: 1518122667860, 136 | likes: [], 137 | replyingTo: "8xf0y6ziyjabvozdd253nd", 138 | replies: [], 139 | }, 140 | "njv20mq7jsxa6bgsqc97": { 141 | id: "njv20mq7jsxa6bgsqc97", 142 | author: "dan_abramov", 143 | text: "Sometimes you have to sacrifice simplicity for power.", 144 | timestamp: 1518044095650, 145 | likes: ['tylermcginnis'], 146 | replyingTo: "5c9qojr2d1738zlx09afby", 147 | replies: [], 148 | }, 149 | "leqp4lzfox7cqvsgdj0e7": { 150 | id: "leqp4lzfox7cqvsgdj0e7", 151 | author: "tylermcginnis", 152 | text: "Also trashcans. Learned this the hard way.", 153 | timestamp: 1516043255650, 154 | likes: [], 155 | replyingTo: "hbsc73kzqi75rg7v1e0i6a", 156 | replies: [], 157 | }, 158 | "sfljgka8pfddbcer8nuxv": { 159 | id: "sfljgka8pfddbcer8nuxv", 160 | author: "dan_abramov", 161 | text: "Puppies are the best.", 162 | timestamp: 1516045995650, 163 | likes: ['sarah_edo', 'tylermcginnis'], 164 | replyingTo: "hbsc73kzqi75rg7v1e0i6a", 165 | replies: [], 166 | }, 167 | "3sklxkf9yyfowrf0o1ftbb": { 168 | id: "3sklxkf9yyfowrf0o1ftbb", 169 | author: "sarah_edo", 170 | text: "The idea of best practices being a negative thing is an interesting concept.", 171 | timestamp: 1515044095650, 172 | likes: ['dan_abramov'], 173 | replyingTo: "czpa59mg577x1oo45cup0d", 174 | replies: [], 175 | }, 176 | "26p5pskqi88i58qmza2gid": { 177 | id: "26p5pskqi88i58qmza2gid", 178 | author: "tylermcginnis", 179 | text: "Too relatable", 180 | timestamp: 1514044994650, 181 | likes: ['sarah_edo'], 182 | replyingTo: "2mb6re13q842wu8n106bhk", 183 | replies: [], 184 | }, 185 | "xi3ca2jcfvpa0i3t4m7ag": { 186 | id: "xi3ca2jcfvpa0i3t4m7ag", 187 | author: "tylermcginnis", 188 | text: "Just DMd you!", 189 | timestamp: 1510043995650, 190 | likes: [], 191 | replyingTo: "6h5ims9iks66d4m7kqizmv", 192 | replies: [], 193 | }, 194 | "r0xu2v1qrxa6ygtvf2rkjw": { 195 | id: "r0xu2v1qrxa6ygtvf2rkjw", 196 | author: "dan_abramov", 197 | text: "This is a great idea.", 198 | timestamp: 1510044395650, 199 | likes: ['tylermcginnis'], 200 | replyingTo: "6h5ims9iks66d4m7kqizmv", 201 | replies: [], 202 | }, 203 | } 204 | 205 | export function _getUsers () { 206 | return new Promise((res, rej) => { 207 | setTimeout(() => res({...users}), 1000) 208 | }) 209 | } 210 | 211 | export function _getTweets () { 212 | return new Promise((res, rej) => { 213 | setTimeout(() => res({...tweets}), 1000) 214 | }) 215 | } 216 | 217 | export function _saveLikeToggle ({ id, hasLiked, authedUser }) { 218 | return new Promise((res, rej) => { 219 | setTimeout(() => { 220 | tweets = { 221 | ...tweets, 222 | [id]: { 223 | ...tweets[id], 224 | likes: hasLiked === true 225 | ? tweets[id].likes.filter((uid) => uid !== authedUser) 226 | : tweets[id].likes.concat([authedUser]) 227 | } 228 | } 229 | 230 | res() 231 | }, 500) 232 | }) 233 | } 234 | 235 | function generateUID () { 236 | return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) 237 | } 238 | 239 | function formatTweet ({ author, text, replyingTo = null }) { 240 | return { 241 | author, 242 | id: generateUID(), 243 | likes: [], 244 | replies: [], 245 | text, 246 | timestamp: Date.now(), 247 | replyingTo, 248 | } 249 | } 250 | 251 | export function _saveTweet ({ text, author, replyingTo }) { 252 | return new Promise((res, rej) => { 253 | const formattedTweet = formatTweet({ 254 | text, 255 | author, 256 | replyingTo 257 | }) 258 | 259 | setTimeout(() => { 260 | tweets = { 261 | ...tweets, 262 | [formattedTweet.id]: formattedTweet, 263 | } 264 | 265 | users = { 266 | ...users, 267 | [author]: { 268 | ...users[author], 269 | tweets: users[author].tweets.concat([formattedTweet.id]) 270 | } 271 | } 272 | 273 | res(formattedTweet) 274 | }, 1000) 275 | }) 276 | } 277 | -------------------------------------------------------------------------------- /src/utils/api.js: -------------------------------------------------------------------------------- 1 | import { 2 | _getUsers, 3 | _getTweets, 4 | _saveLikeToggle, 5 | _saveTweet, 6 | } from './_DATA.js' 7 | 8 | export function getInitialData () { 9 | return Promise.all([ 10 | _getUsers(), 11 | _getTweets(), 12 | ]).then(([users, tweets]) => ({ 13 | users, 14 | tweets, 15 | })) 16 | } 17 | 18 | export function saveLikeToggle (info) { 19 | return _saveLikeToggle(info) 20 | } 21 | 22 | export function saveTweet (info) { 23 | return _saveTweet(info) 24 | } -------------------------------------------------------------------------------- /src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | export function formatDate (timestamp) { 2 | const d = new Date(timestamp) 3 | const time = d.toLocaleTimeString('en-US') 4 | return time.substr(0, 5) + time.slice(-2) + ' | ' + d.toLocaleDateString() 5 | } 6 | 7 | export function formatTweet (tweet, author, authedUser, parentTweet) { 8 | const { id, likes, replies, text, timestamp } = tweet 9 | const { name, avatarURL } = author 10 | 11 | return { 12 | name, 13 | id, 14 | timestamp, 15 | text, 16 | avatar: avatarURL, 17 | likes: likes.length, 18 | replies: replies.length, 19 | hasLiked: likes.includes(authedUser), 20 | parent: !parentTweet ? null : { 21 | author: parentTweet.author, 22 | id: parentTweet.id, 23 | } 24 | } 25 | } --------------------------------------------------------------------------------