diff --git a/app/package.json b/app/package.json index 8b6a8d0..664592c 100644 --- a/app/package.json +++ b/app/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "core-js": "^3.6.4", + "date-fns": "^2.9.0", "vue": "^2.6.11", "vue-router": "^3.1.3", "vue-simple-spinner": "^1.2.8" diff --git a/app/src/components/NavBar.vue b/app/src/components/NavBar.vue index 3240387..c6c17cd 100644 --- a/app/src/components/NavBar.vue +++ b/app/src/components/NavBar.vue @@ -5,7 +5,7 @@ diff --git a/app/src/components/NewsList.vue b/app/src/components/NewsList.vue index 127101c..6b191d5 100644 --- a/app/src/components/NewsList.vue +++ b/app/src/components/NewsList.vue @@ -1,16 +1,18 @@ @@ -32,10 +34,14 @@ export default { }; - diff --git a/app/src/components/NewsListItem.vue b/app/src/components/NewsListItem.vue index 07311be..49ccd58 100644 --- a/app/src/components/NewsListItem.vue +++ b/app/src/components/NewsListItem.vue @@ -4,10 +4,12 @@ {{ upvotes }}
-

{{ title }}

+

+ {{ title }} +

{{ author }}
-
{{ timestamp }}
+
{{ ago }}
{{ numComments }} comments
@@ -18,13 +20,25 @@ @@ -58,6 +72,11 @@ export default { color: #333; } +.title a { + text-decoration: none; + color: inherit; +} + .meta { color: #888; font-size: 0.8em; diff --git a/app/src/components/Paginator.vue b/app/src/components/Paginator.vue index e69de29..a0c4e47 100644 --- a/app/src/components/Paginator.vue +++ b/app/src/components/Paginator.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/app/src/lib/api.js b/app/src/lib/api.js new file mode 100644 index 0000000..e0ca56b --- /dev/null +++ b/app/src/lib/api.js @@ -0,0 +1,17 @@ +const baseURL = 'https://hacker-news.firebaseio.com/v0'; + +const fetchItem = id => { + return fetch(`${baseURL}/item/${id}.json`).then(res => res.json()); +} + +const getTopStories = () => { + return fetch(baseURL + '/topstories.json').then(res => res.json()); +}; + +export const getPaginatedTopStories = async (page = 1, count = 10) => { + const allTopStories = await getTopStories(); + + const idsToFetch = allTopStories.slice((page - 1) * count, page * count); + + return Promise.all(idsToFetch.map(fetchItem)); +}; diff --git a/app/src/router/index.js b/app/src/router/index.js index 59b0282..7579266 100644 --- a/app/src/router/index.js +++ b/app/src/router/index.js @@ -10,6 +10,14 @@ const routes = [ { path: '/', name: 'home', + redirect: { name: 'top' } + }, + { + path: '/top/:page?', + params: { + page: 1 + }, + name: 'top', component: Home }, { diff --git a/app/src/views/Home.vue b/app/src/views/Home.vue index 6f32aa6..fee2853 100644 --- a/app/src/views/Home.vue +++ b/app/src/views/Home.vue @@ -1,170 +1,61 @@ diff --git a/app/yarn.lock b/app/yarn.lock index c1b186a..0673e94 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2640,6 +2640,11 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +date-fns@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.9.0.tgz#d0b175a5c37ed5f17b97e2272bbc1fa5aec677d2" + integrity sha512-khbFLu/MlzLjEzy9Gh8oY1hNt/Dvxw3J6Rbc28cVoYWQaC1S3YI4xwkF9ZWcjDLscbZlY9hISMr66RFzZagLsA== + de-indent@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" diff --git a/lessons/09-fetching-data.md b/lessons/09-fetching-data.md new file mode 100644 index 0000000..5dcfe25 --- /dev/null +++ b/lessons/09-fetching-data.md @@ -0,0 +1,316 @@ +# Lesson 9 + +In this lesson, we'll connect to the publicly available HackerNews API and load real data onto our news list. We'll also implement the `` component, which will be linked to our router. There's quite a lot of stuff in this lesson so hop in. + +## Fetching data + +We'll first implement some convenient functions that'll use the Fetch API to get the data we need. We'll create these in `src/lib/api.js`: + +```javascript +const baseURL = 'https://hacker-news.firebaseio.com/v0'; + +const fetchItem = id => { + return fetch(`${baseURL}/item/${id}.json`).then(res => res.json()); +}; + +const getTopStories = () => { + return fetch(baseURL + '/topstories.json').then(res => res.json()); +}; + +export const getPaginatedTopStories = async (page = 1, count = 10) => { + const allTopStories = await getTopStories(); + + const idsToFetch = allTopStories.slice((page - 1) * count, page * count); + + return Promise.all(idsToFetch.map(fetchItem)); +}; +``` + +While you could handcraft these APIs yourself, that's not really necessary. The goal is to show how we can get the data loaded into our components for rendering. The functions implemented above are pretty straightforward, although we could've certainly optimized many things around: + +- `getTopStories`: fetches IDs of the top stories. About 500 of them by default. +- `fetchItem`: given an ID, gets the JSON representation of a news item. +- `getPaginatedTopStories`: combines above two functions to get us an array of items for a given page number. + +## Adding dynamic params to our routes + +We'll do a little refactoring of our routes. What we want to do now is: + +1. If user visits `/`, redirect to `/top` +2. If user visits `/top/2`, load data for page 2. + +So, we'll let our routes indicate part of our state. + +`vue-router` allows us to define dynamic components in our routes: + +```javascript +const routes = [ + { + path: '/', + name: 'home', + redirect: { name: 'top' } + }, + { + path: '/top/:page?', + name: 'top', + component: Home + }, + { + path: '/new', + name: 'new', + component: Home + }, + { + path: '/about', + name: 'about', + component: About + } +]; +``` + +As you can see above, we can indicate dynamic components of a route with `:param` syntax. We can access this from our components via the `this.$route.params` object. For the `:page` param above, that'd be `this.$route.params.page`. + +The other cool thing about this is that `$route.params` is reactive! As we'll see later, we'd be able to re-fetch the data when the param changes as a result of us clicking within our paginator component. + +Let's also make a small change in the `components/NavBar.vue` file so that "Top" link gets active state correctly: + +```html +Top +``` + +## Passing down the fetched data + +Let's go to `views/Home.vue` and make some changes: + +```vue + + + +``` + +Let's go over the changes. `` would need the current page we're at and the total number of pages. We create two computed properties: `currentPage` and `totalPages` that does this. As mentioned before, we can access the current page by `this.$route.params.page`. This will be a string value which we convert to number by using the unary `+` operator. If this param isn't present, we default to 1. + +`totalPages` is straightforward. We just return the length of the `items` data property. + +We also create a dummy method called `fetchStories` which we'll populate later. + +We use a watcher to notify whenever the `currentPage` computed property changes. `immediate: true` runs the `handler` function once at the beginning when the component is mounted, otherwise, it only gets run when the value of reactive property changes later. As you can see, it simply calls the `this.fetchStories` method which will make the API call and sets the `this.items` property. Let's code that up: + +```javascript +import { getPaginatedTopStories } from '@/lib/api'; + +// .. + +async fetchStories() { + const items = await getPaginatedTopStories(this.currentPage, 20); + + this.items = items.map(item => ({ + title: item.title, + author: item.by, + upvotes: item.score, + timestamp: new Date(item.time * 1000).toUTCString(), + commentCount: (item.kids || []).length + })); +} +``` + +Nothing too complicated. Although, we're mapping over the items we receive into the format we're expecting when we pass it down to ``. Alright, try saving and reload. You should see actual data being fetched and rendered! Alright! πŸ™Œ We're not done yet, we need to implement ``. + +## Building `` + +Remember, we're determining the current page state via router params. So, when someone clicks the Next/Previous buttons, we basically need to change the route. The reactivity takes care of the rest. + +You have two choices here: + +1. Use `` for the prev/next buttons and simply pass a `params` property with `page: currentPage - 1` and `page: currentPage + 1` respectievely. +2. Use the `this.$router.push()` function to do it imperatively from within a method. It accepts a route object, just like ``. Give it a shot on your own. Take a peek at the below implementation for some inspiration. + +```vue + + + + + +``` + +Try it out. You should now see that clicking the prev/next buttons would not only change the route, but also fetch data for the page you're viewing. Neato! + +## Improvising + +Let's add the popular `date-fns` package to format the timestamp value we receive into a more, human-readable string: + +```sh +yarn add date-fns +``` + +Open `components/NewsListItem.vue`: + +```diff + + + +``` + +## What next? + +Nice. You have a working HackerNews clone written in Vue.js. You could do so much more: + +1. Add a user view, item view with comment threads +2. Link to the actual post via the `url` from item response +3. Load data more efficiently: you could use the firebase SDK for realtime updates and cache already seen item responses so that you don't have to re-fetch them. +4. Make it a PWA! + +There's also lot more to learn about Vue.js. Check the excellent Vue.js docs: https://vuejs.org/