├── .babelrc ├── .gitignore ├── .travis.yml ├── .zuul.yml ├── LICENSE ├── package.json ├── readme.md ├── src ├── components │ ├── comments │ │ ├── item.js │ │ └── list.js │ ├── info-bar.js │ ├── item-list │ │ ├── item.js │ │ └── list.js │ ├── item │ │ ├── index.js │ │ └── poll-options.js │ ├── loading.js │ ├── root.js │ └── url.js ├── effects.js ├── hackernews-api.js ├── index.css ├── index.js ├── model.js ├── pages │ ├── home.js │ ├── item.js │ └── user.js ├── reducers.js ├── storage.js ├── subscriptions.js └── utils │ └── index.js └── test ├── components ├── comments-item.js ├── comments-list.js ├── info-bar.js ├── item.js ├── itemlist-item.js ├── itemlist-list.js ├── loading.js ├── poll-options.js └── url.js ├── fixtures ├── comment.js ├── item.js └── poll-options.js └── pages ├── home.js ├── item.js └── user.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-strict-mode"], 3 | "presets": ["es2015"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .zuulrc 4 | dist 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '5' 5 | script: npm run test-sauce 6 | env: 7 | global: 8 | - secure: R1N2x2svxvwY6pYRTJRoEZmKYaXBPf3pKSsjHVP6AtZeGCFrFkj19BTtI93TSf2jnV0KOyC1sxnBFqGpsQpmKyz7UflTLv7tnt1YD+mptWb6KAKezy39iGvDrXQjnlQehnSNV3NbFQnEsVxHMxTtqctBXbIGWloSZodY8xni5bv3CmZsY5j+sThTEkzyW/uItL+Ato/CBjVwF9YzD7QuAiooNhb99xsS0D4wk3rQZ+IZkkLe7wsA004rQyOIQyIb7I8xZvr3EnGCwBnUujicw4wUjsKAjSfSwWWBuJbPK3M8+BEDroHnaT3Ff/2hpX5cyTt91RCVc7kq7Q2JywNZz00V1vuyK+cdnQfmvqCH+uFnOjr0sEMfahm9zzUNbcGYcL+4PnvEWf0lenxOL5+1/2ZrdVYzsLUQv2wflxIdZ8hQJ6WlK9SMaL5sbpjhh5SiJSEfNwkZZLWxc+BR4HUkG51HYB2IUuYRNxqUwRfjdiRtbFODs6TiNmeParl5aIh12hstkqNnfgKrXp8t//c9PJk7ngxhblIT8I0IJfoEdqFhteksFzn2ohh2gLGbdXCkGiXT7AzoWBuvc1Ujehrp2yBWzClO8z+OLeaJlnfXI76FxHo710kS/zEln9Y5zQnt79BTrjhSSnifjd7y+jkQPciWZKVylUNY7kAa/PWJ9Cw= 9 | - secure: PDcaeOU/ewwPuH8WOR8wfU2VddUIlPurjATpFv+wHQ+5FFVXeq6JfwSHlVzP2sMkMUMXSt12tdY1UZhEr7UFXRmjEnlRjs4Xzpak+a7GH9fyOFfreHUqYAMcibOnQk5aFZtSdXdN1Ln7bROxNFUaFU6NolwXxdbEl3MeGgyySgiMFFPrLt2zqY/QrT4uVj/1E059q0Wjvm6HWEIIoi7JF1QYtrcMWn1MsP8UB2t3PX+emW5xu3XgkGQ/mpTsQtx2+8EO7NFnXRW/xwixOMeQGbdATXKJv1RqSqRl6lkqwUbAMS0RFu9TYdbuVMrDYyecmrfrGpnYGF5LzE8/jXYZ/cl2ow6fYyi4vYiYTtybEmkWwZXbOWILu+DwbUuvAtug53tz5q4id/VLt0pO26Fef++/0HRdd8sx/iP7YNk+BKmXzleqUEpcHEYuxag8t2EPZfRSqLQHJKv5t6KX3OFtPEW1eUvR40otwyAlCkQvl4r3cTYq+mP9NrHSTzvC7O0fkLlwlksssbxaonXSzAj/6hJFapusiDj8DPln4gIvVdT0Ug03x2hxP7Scwi4H0bOBuR9HMQuzh2vS+r900xh7Ea7owQIdYpdUkJjhLMMY4AgKPQYj+gKpwKdQANPSiifFq7zW0pvqkkxhN6TfpUzsmO0zRbuU7SJtTyt69LITVPM= 10 | - secure: ZfOhS0D1LEsHX1gyjXyTGjmSs72074I1C1UPYafH5E1WGcfolnxlVdBc1JmFAW3czB+j3wC4Zfi2hg+gTXxSsHIqt6RFS6pMVmtMmgGXzU3EWqY0WyQWGaxhLd1SCoLTYDhy7odGHj96C04oxX7r8KM8m7BhFr8KT+FJfigOHvabd2NIMTCYLd1U1ZuCpsxU+TiAmFD0elsxl2TfnuubbsiQYt4utMRrlFeV1Ji8cC2lOjO/79o1dyAt1SNzD0BD1r86xBTbtKAiDwdsS1EFbURMfT+zWOH3nwrdyhR6eoflxS3xsdpOPDmKj3AVsOpFWxex+CoRlJmzCwthUGkVnp/sopencNd6ni4jU4bLVBFsOgHYXtgXHjhdnuV0NNd9L+cXm3rR+5fHx59XniX+/yV4dE5RtFgw1h5brMI1EaT8yNTFpR4LJMAwA67XhvzYTdUwLAuEytEaVNqZhySNhNRZ1mc8EPXp9Pi9Xu+NOZnscg1fnRMedFriubxqolx0DCWjGXicHo9WlJaGLuRu+dzCyR0SIUG9jhWxDfh1UqScnX4GVXjyMCBv58nGpNJ448IpDl+9/PWGrSRAPuRl8+jLH3QbCQZ1EwZgC++bxrUQGu1UwdDxBnyCWrdZDrWzMdH+/3dB+5MB/BnypQqZXfwbWfPCDu+oBgcbQGwAcho= 11 | -------------------------------------------------------------------------------- /.zuul.yml: -------------------------------------------------------------------------------- 1 | ui: tape 2 | browsers: 3 | - name: chrome 4 | version: latest 5 | os: 'Mac 10.11' 6 | - name: firefox 7 | version: latest 8 | os: 'Mac 10.11' 9 | - name: microsoftedge 10 | version: latest 11 | - name: safari 12 | version: latest 13 | os: 'Mac 10.11' 14 | - name: iphone 15 | version: '8.4..9.2' 16 | - name: android 17 | version: '5.1' 18 | - name: opera 19 | version: latest 20 | platform: 'Windows 10' 21 | concurrency: 2 22 | tunnel: 23 | type: ngrok 24 | bind_tls: true 25 | browserify: 26 | - transform: yo-yoify 27 | - transform: sheetify/transform 28 | - transform: babelify 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kevin Neff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackernews-choo", 3 | "version": "1.0.0", 4 | "description": "Hacker News Clone using Choo", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "budo src/index.js -P -s=index.js --live --open -- -t yo-yoify -t sheetify/transform -t babelify", 8 | "deploy": "npm run build && surge dist hackernews-choo.surge.sh", 9 | "build": "mkdir -p dist && browserify src/index.js -o dist/index.js -t yo-yoify -t sheetify/transform -t babelify -g uglifyify -p [css-extract -o dist/bundle.css] src/index.js", 10 | "test": "zuul --local 8080 -- test/**/*.js", 11 | "test-sauce": "zuul -- test/**/*.js" 12 | }, 13 | "browserify": { 14 | "transforms": [ 15 | "yo-yoify", 16 | "sheetify/transform", 17 | "babelify" 18 | ] 19 | }, 20 | "author": "Kevin Neff", 21 | "license": "MIT", 22 | "repository": "https://github.com/kvnneff/hackernews-choo", 23 | "dependencies": { 24 | "approximate-time": "0.0.1", 25 | "choo": "^3.2.0", 26 | "ent": "^2.2.0", 27 | "firebase": "2.3.1", 28 | "sanitize-html": "^1.12.0", 29 | "sheetify": "^5.0.2", 30 | "tachyons": "^4.0.1", 31 | "loaders.css": "^0.1.2" 32 | }, 33 | "devDependencies": { 34 | "babel-preset-es2015": "^6.9.0", 35 | "babelify": "^7.3.0", 36 | "browserify": "^13.0.1", 37 | "budo": "git://github.com/mattdesl/budo.git#9199226", 38 | "component-emitter": "^1.2.1", 39 | "css-extract": "^1.1.1", 40 | "surge": "^0.18.0", 41 | "tape": "^4.6.0", 42 | "uglifyify": "^3.0.2", 43 | "yo-yoify": "^3.4.0", 44 | "zuul": "^3.10.3", 45 | "zuul-ngrok": "^4.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # hackernews-choo [![Build Status](https://travis-ci.org/kvnneff/hackernews-choo.svg?branch=master)](https://travis-ci.org/kvnneff/hackernews-choo) 2 | 3 | A Hacker News reader built to demonstrate [Choo](https://github.com/yoshuawuyts/choo). 4 | 5 | [View online](https://hackernews-choo.surge.sh). 6 | 7 | ## Development 8 | 9 | `npm start` 10 | 11 | ## Testing 12 | 13 | `npm test` 14 | 15 | ## Browser support 16 | 17 | [![Sauce Test Status](https://saucelabs.com/browser-matrix/river-grimm.svg)](https://saucelabs.com/u/river-grimm) 18 | 19 | ## License 20 | [MIT](https://tldrlegal.com/license/mit-license) 21 | -------------------------------------------------------------------------------- /src/components/comments/item.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const decode = require('ent/decode') 3 | const sanitizeHTML = require('sanitize-html') 4 | const approx = require('approximate-time') 5 | 6 | const Comment = state => { 7 | if (state.item && state.item.deleted) return '' 8 | const item = state.item 9 | const commentBody = h`

` 10 | 11 | commentBody.innerHTML = sanitizeHTML(decode(item.text)) 12 | 13 | return h`` 27 | } 28 | 29 | const timeAgo = item => { 30 | let posted = approx(item.time * 1000) 31 | if (posted !== 'just now') posted = `${posted} ago` 32 | return h`${posted}` 33 | } 34 | 35 | module.exports = Comment 36 | -------------------------------------------------------------------------------- /src/components/comments/list.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const sf = require('sheetify') 3 | const CommentItem = require('./item') 4 | 5 | const prefix = sf` 6 | :host > li { 7 | padding: 1rem; 8 | margin: 2rem 0; 9 | background-color: #f4f4f4; 10 | border: 1px solid #ccc; 11 | } 12 | 13 | :host > li:nth-child(even) { 14 | background-color: #fefefe; 15 | } 16 | ` 17 | 18 | const CommentsList = state => { 19 | const comments = state.comments 20 | 21 | if (!comments || !comments.length) return '' 22 | 23 | return h`` 28 | } 29 | 30 | module.exports = CommentsList 31 | -------------------------------------------------------------------------------- /src/components/info-bar.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const approx = require('approximate-time') 3 | 4 | const InfoBar = state => { 5 | const item = state.item 6 | 7 | if (item.type !== 'story' && item.type !== 'poll') { 8 | return h`
${timeAgo(item)}
` 9 | } 10 | 11 | return h`
12 | ${score(item)} | ${commentsLink(item)} 13 |
` 14 | } 15 | 16 | const timeAgo = item => { 17 | let posted = approx(item.time * 1000) 18 | if (posted !== 'just now') posted = `${posted} ago` 19 | return h`${posted}` 20 | } 21 | 22 | const score = item => { 23 | return h` 24 | ${item.score} points by 25 | 26 | ${item.by} 27 | 28 | ${timeAgo(item)} 29 | ` 30 | } 31 | 32 | const commentsLink = item => { 33 | return h`${item.descendants} comments` 34 | } 35 | 36 | module.exports = InfoBar 37 | -------------------------------------------------------------------------------- /src/components/item-list/item.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const URL = require('../url') 3 | const InfoBar = require('../info-bar') 4 | 5 | module.exports = function StoryItem (state) { 6 | const { item, index } = state 7 | const url = item.url ? item.url : `/item/${item.id}` 8 | const domain = URL({ url }) 9 | 10 | return h` 11 | ${index}. 12 | 13 | 14 | ${item.title.replace(/\s([^\s<]+)\s*$/, '\u00A0$1')} 15 | 16 | ${domain} 17 | ${InfoBar({ item })} 18 | 19 | ` 20 | } 21 | -------------------------------------------------------------------------------- /src/components/item-list/list.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const Item = require('./item') 3 | 4 | module.exports = function StoryList (state) { 5 | const { collection, pageNumber, storiesPerPage } = state 6 | let index = (pageNumber - 1) * storiesPerPage 7 | 8 | return h`
9 | 10 | 11 | ${collection.map((item) => { 12 | index++ 13 | return Item({ index, item }) 14 | })} 15 | 16 |
17 |
` 18 | } 19 | -------------------------------------------------------------------------------- /src/components/item/index.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const URL = require('../url') 3 | const InfoBar = require('../info-bar') 4 | const PollOptions = require('./poll-options') 5 | 6 | const Item = state => { 7 | const { item, pollOptions, onLoad } = state 8 | return h`
9 | ${item.title} 10 | ${URL(item)} 11 | ${InfoBar({ item })} 12 | ${item.text ? itemText(item.text) : ''} 13 | ${item.type === 'poll' ? PollOptions({ pollOptions }) : ''} 14 |
` 15 | } 16 | 17 | function itemText (text) { 18 | const itemText = h`

` 19 | itemText.innerHTML = text 20 | return itemText 21 | } 22 | 23 | module.exports = Item 24 | -------------------------------------------------------------------------------- /src/components/item/poll-options.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const sanitizeHTML = require('sanitize-html') 3 | const decode = require('ent/decode') 4 | 5 | const PollOptions = state => { 6 | const pollOptions = state.pollOptions 7 | 8 | const options = pollOptions 9 | .filter(option => { if (!option.deleted) return true }) 10 | .map(option => { 11 | const optionText = h`` 12 | optionText.innerHTML = sanitizeHTML(decode(option.text)) 13 | return h`
  • 14 | ${optionText} 15 | ${option.score} points 16 |
  • ` 17 | }) 18 | 19 | return h`` 22 | } 23 | 24 | module.exports = PollOptions 25 | -------------------------------------------------------------------------------- /src/components/loading.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const sheetify = require('sheetify') 3 | 4 | const prefix = sheetify` 5 | .ball-pulse > div { 6 | background-color: #006C71; 7 | } 8 | ` 9 | const initialState = { text: null } 10 | 11 | module.exports = function loading (state = initialState) { 12 | const { text } = state 13 | return h`
    14 |
    ${text ? text : 'Loading...'}
    15 |
    16 |
    17 |
    18 |
    19 |
    20 |
    ` 21 | } 22 | -------------------------------------------------------------------------------- /src/components/root.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const sheetify = require('sheetify') 3 | 4 | const Root = content => { 5 | return h`
    6 |
    7 |

    Hacker News

    8 |
    9 |
    10 | ${content} 11 |
    12 | ${footer()} 13 |
    ` 14 | } 15 | 16 | const footerPrefix = sheetify` 17 | :host { 18 | position: absolute; 19 | right: 0; 20 | bottom: 0; 21 | left: 0; 22 | padding: 1rem; 23 | } 24 | ` 25 | 26 | function footer () { 27 | return h`` 33 | } 34 | 35 | module.exports = Root 36 | -------------------------------------------------------------------------------- /src/components/url.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | 3 | const URL = state => { 4 | if (!state || !state.url || state.url.substring(0, 6) === '/item/') return '' 5 | const matches = state.url.match(/^https?\:\/\/([^\/?#]+)(?:[\/?#]|$)/i) 6 | const domain = matches && matches[1] 7 | return h`(${domain})` 8 | } 9 | 10 | module.exports = URL 11 | -------------------------------------------------------------------------------- /src/effects.js: -------------------------------------------------------------------------------- 1 | const api = require('./hackernews-api') 2 | 3 | const fetchUser = (userId, state, dispatch, done) => { 4 | api.fetchUser(userId, (err, user) => { 5 | if (err) return done(err) 6 | dispatch('updateUser', user, done) 7 | }) 8 | } 9 | 10 | const fetchAllTopStoryIds = (action, state, dispatch, done) => { 11 | api.fetchAllTopStoryIds((err, ids) => { 12 | if (err) return done(err) 13 | return dispatch('updateIds', ids, () => { 14 | return done(null, ids) 15 | }) 16 | }) 17 | } 18 | 19 | const fetchItems = (itemIds, state, dispatch, done) => { 20 | if (!Array.isArray(itemIds)) itemIds = [itemIds] 21 | api.fetchItems(itemIds, (err, collection) => { 22 | if (err) return done(err) 23 | dispatch('updateCollection', { payload: collection }, done) 24 | }) 25 | } 26 | 27 | const fetchItemsByPage = (action, state, dispatch, done) => { 28 | const pageNumber = action 29 | const start = (pageNumber - 1) * state.storiesPerPage 30 | const end = pageNumber * state.storiesPerPage 31 | const cache = state.storyCollection 32 | 33 | const run = (err, topStoryIds) => { 34 | if (err) throw err 35 | const ids = topStoryIds.slice(start, end) 36 | 37 | const idsToFetch = ids.filter(id => { 38 | if (!cache.get(id)) return true 39 | }) 40 | 41 | if (!idsToFetch.length) { 42 | return dispatch('updateCurrentItems', ids, () => { 43 | return dispatch('isLoadingItems', false, done) 44 | }) 45 | } 46 | 47 | api.fetchItems(idsToFetch, (err, collection) => { 48 | if (err) return done(err) 49 | dispatch('updateCollection', { payload: collection }, () => { 50 | dispatch('updateCurrentItems', ids, () => { 51 | return dispatch('isLoadingItems', false, done) 52 | }) 53 | }) 54 | }) 55 | } 56 | 57 | dispatch('isLoadingItems', true, () => { 58 | if (!state.topStoryIds.length) { 59 | return dispatch('fetchAllTopStoryIds', {}, run) 60 | } 61 | return run(null, state.topStoryIds) 62 | }) 63 | } 64 | 65 | const fetchPollOptions = (action, state, dispatch, done) => { 66 | const cache = state.storyCollection 67 | const itemId = action 68 | const item = cache.get(itemId) 69 | 70 | dispatch('isLoadingPollOptions', true, () => { 71 | api.fetchItems(item.parts, collection => { 72 | dispatch('updatePollOptions', { collection, itemId }, () => { 73 | dispatch('isLoadingPollOptions', false, done) 74 | }) 75 | }) 76 | }) 77 | } 78 | 79 | const fetchComments = (itemId, state, dispatch, done) => { 80 | const storyCollection = state.storyCollection 81 | const item = storyCollection.get(itemId) 82 | dispatch('isLoadingComments', true, () => { 83 | api.fetchChildren(item, (err, newItem) => { 84 | if (err) return done(err) 85 | const payload = newItem.children.slice() 86 | dispatch('updateComments', { payload, itemId }, () => { 87 | dispatch('isLoadingComments', false, done) 88 | }) 89 | }) 90 | }) 91 | } 92 | 93 | module.exports = { 94 | fetchUser, 95 | fetchAllTopStoryIds, 96 | fetchItemsByPage, 97 | fetchPollOptions, 98 | fetchComments, 99 | fetchItems 100 | } 101 | -------------------------------------------------------------------------------- /src/hackernews-api.js: -------------------------------------------------------------------------------- 1 | const Firebase = require('firebase') 2 | const hn = new Firebase('https://hacker-news.firebaseio.com/v0/') 3 | 4 | const subscribe = (cb) => { 5 | hn.child('topstories').on('value', snapshot => { 6 | const ids = snapshot.val() 7 | return cb(null, ids) 8 | }) 9 | } 10 | 11 | const fetchItems = (itemIds, cb) => { 12 | const newCollection = new Map() 13 | itemIds.forEach(itemId => fetchItem(itemId, (err, item) => { 14 | if (err) throw err 15 | newCollection.set(itemId, item) 16 | if (newCollection.size >= itemIds.length) { 17 | return cb(null, newCollection) 18 | } 19 | })) 20 | } 21 | 22 | const fetchUser = (userId, cb) => { 23 | hn.child('user/' + userId).once('value', snapshot => { 24 | return cb(null, snapshot.val()) 25 | }) 26 | } 27 | 28 | const fetchAllTopStoryIds = (cb) => { 29 | hn.child('topstories').once('value', snapshot => { 30 | return cb(null, snapshot.val()) 31 | }) 32 | } 33 | 34 | const fetchItem = (itemId, cb) => { 35 | hn.child(`item/${itemId}`).once('value', snapshot => { 36 | const item = snapshot.val() 37 | return cb(null, item) 38 | }) 39 | } 40 | 41 | const fetchChildren = (story, cb) => { 42 | story.children = [] 43 | if (story.descendants === 0) return cb(null, story) 44 | let count = 0 45 | 46 | const trace = item => { 47 | if (item.kids) { 48 | item.kids.forEach(id => { 49 | count++ 50 | hn.child(`item/${id}`) 51 | .once('value', snapshot => { 52 | const fetchedItem = snapshot.val() 53 | fetchedItem.children = [] 54 | item.children.push(fetchedItem) 55 | if (fetchedItem.kids) { 56 | trace(fetchedItem) 57 | } 58 | count-- 59 | if (count === 0) return done() 60 | }) 61 | }) 62 | } 63 | } 64 | 65 | function done () { 66 | return cb(null, story) 67 | } 68 | 69 | trace(story) 70 | } 71 | 72 | module.exports = { 73 | fetchItems, 74 | fetchItem, 75 | fetchChildren, 76 | subscribe, 77 | fetchAllTopStoryIds, 78 | fetchUser 79 | } 80 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | min-height: 100%; 7 | position: relative; 8 | margin: 0; 9 | padding-bottom: 6rem; 10 | } 11 | 12 | a { 13 | color: #006c71; 14 | text-decoration: none; 15 | } 16 | 17 | a:hover { 18 | color: #f00008; 19 | } 20 | 21 | pre { 22 | overflow: auto; 23 | max-width: 600px; 24 | background: #f4f4f4; 25 | } 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo') 2 | const sheetify = require('sheetify') 3 | const storage = require('./storage') 4 | const StoryList = require('./pages/home') 5 | const Item = require('./pages/item') 6 | const User = require('./pages/user') 7 | const hooks = {} 8 | 9 | sheetify('tachyons') 10 | sheetify('loaders.css') 11 | sheetify('./index.css', { global: true }) 12 | 13 | hooks.onStateChange = (action, state) => { storage.save(state) } 14 | hooks.onError = err => { console.error(err) } 15 | 16 | const app = choo(hooks) 17 | 18 | app.model(require('./model')) 19 | 20 | app.router(route => [ 21 | route(`/`, StoryList), 22 | route(`/page/:pageNumber`, StoryList), 23 | route(`/item/:itemId`, Item), 24 | route(`/user/:userId`, User) 25 | ]) 26 | 27 | const tree = app.start() 28 | document.body.appendChild(tree) 29 | -------------------------------------------------------------------------------- /src/model.js: -------------------------------------------------------------------------------- 1 | const storage = require('./storage') 2 | const reducers = require('./reducers') 3 | const effects = require('./effects') 4 | const subscriptions = require('./subscriptions') 5 | 6 | const initialState = { 7 | storyCollection: new Map(), 8 | commentCollection: new Map(), 9 | pollOptionCollection: new Map(), 10 | currentItems: [], 11 | topStoryIds: [], 12 | storiesPerPage: 30, 13 | currentPage: 1, 14 | user: null, 15 | fetchingComments: false, 16 | selectedItem: 0, 17 | isLoadingStories: false 18 | } 19 | 20 | module.exports = { 21 | state: storage.get() || initialState, 22 | effects, 23 | reducers, 24 | subscriptions 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/home.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const ItemList = require('../components/item-list/list') 3 | const Loading = require('../components/loading') 4 | const Root = require('../components/root') 5 | 6 | const Home = (state, prevState, dispatch) => { 7 | const { 8 | storyCollection, 9 | storiesPerPage, 10 | isLoadingItems, 11 | currentItems, 12 | params 13 | } = state 14 | const collection = [] 15 | const prevParams = prevState.params 16 | const pageNumber = parseInt(params.pageNumber, 10) || 1 17 | const prevPageNumber = prevParams 18 | ? parseInt(prevState.params.pageNumber, 10) || 1 19 | : 1 20 | let ListView 21 | let NavigationView 22 | 23 | // Load stories if this is a new page 24 | if (prevPageNumber !== parseInt(pageNumber, 10)) { 25 | loadStories() 26 | return LoadingView() 27 | } 28 | 29 | function loadStories () { 30 | dispatch('fetchItemsByPage', pageNumber) 31 | window.scrollTo(0, 0) 32 | } 33 | 34 | if (currentItems.length > 0) { 35 | currentItems.forEach(key => { 36 | collection.push(storyCollection.get(key)) 37 | }) 38 | } 39 | 40 | if (isLoadingItems) { 41 | ListView = LoadingView() 42 | NavigationView = '' 43 | } else { 44 | ListView = ItemList({ collection, storiesPerPage, pageNumber }) 45 | NavigationView = Navigation({ pageNumber }) 46 | } 47 | 48 | return Root(h`
    49 | ${[ 50 | ListView, 51 | NavigationView 52 | ]} 53 |
    `) 54 | } 55 | 56 | const LoadingView = () => { 57 | return h`
    ${Loading({ text: 'Fetching Stories...' })}
    ` 58 | } 59 | 60 | const Navigation = (state) => { 61 | const pageNumber = state.pageNumber 62 | const moreEl = h`More` 63 | let navEl = moreEl 64 | 65 | if (pageNumber > 1) navEl = h`Previous | ${moreEl}` 66 | 67 | return h`
    68 | ${navEl} 69 |
    ` 70 | } 71 | 72 | module.exports = Home 73 | -------------------------------------------------------------------------------- /src/pages/item.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const StoryItem = require('../components/item') 3 | const Loading = require('../components/loading') 4 | const CommentList = require('../components/comments/list') 5 | const Root = require('../components/root') 6 | 7 | module.exports = function Item (state, prevState, dispatch) { 8 | const { 9 | storyCollection, 10 | commentCollection, 11 | pollOptionCollection, 12 | params 13 | } = state 14 | 15 | const itemId = parseInt(params.itemId, 10) 16 | const item = storyCollection.get(itemId) 17 | const comments = commentCollection.get(itemId) 18 | let pollOptions = pollOptionCollection.get(itemId) 19 | 20 | if (!item) { 21 | dispatch('fetchItems', itemId) 22 | return Root(Loading({ text: 'Loading story...' })) 23 | } 24 | 25 | if (item.type === 'poll' && !pollOptions) { 26 | dispatch('fetchPollOptions', item.id) 27 | return Root(Loading({ text: 'Loading poll...' })) 28 | } else if (item.type === 'poll' && pollOptions) { 29 | pollOptions = Array.from(pollOptions.values()) 30 | } 31 | 32 | function fetchComments () { 33 | if (!item) return 34 | dispatch('fetchComments', item.id) 35 | } 36 | 37 | const Story = StoryItem({ item, pollOptions, onLoad: fetchComments }) 38 | const Comments = comments 39 | ? CommentList({ comments }) 40 | : Loading({ text: 'Loading comments...' }) 41 | 42 | return Root(h`
    ${[ Story, Comments ]}
    `) 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/user.js: -------------------------------------------------------------------------------- 1 | const h = require('choo/html') 2 | const sanitizeHTML = require('sanitize-html') 3 | const approx = require('approximate-time') 4 | const Loading = require('../components/loading') 5 | const Root = require('../components/root') 6 | 7 | const User = (state, prevState, dispatch) => { 8 | const { userId } = state.params 9 | const { user } = state 10 | 11 | if (!user || userId !== user.id) { 12 | dispatch('fetchUser', userId) 13 | return Loading({ text: 'Fetching user...' }) 14 | } 15 | 16 | return Root(h``) 39 | } 40 | 41 | const timeAgo = timestamp => { 42 | let posted = approx(timestamp * 1000) 43 | if (posted !== 'just now') posted = `${posted} ago` 44 | return h`${posted}` 45 | } 46 | 47 | const About = (text) => { 48 | if (!text) return '' 49 | const aboutEl = h`

    ` 50 | aboutEl.innerHTML = sanitizeHTML(text) 51 | 52 | return h`
    53 |

    About

    54 | ${aboutEl} 55 |
    ` 56 | } 57 | 58 | module.exports = User 59 | -------------------------------------------------------------------------------- /src/reducers.js: -------------------------------------------------------------------------------- 1 | const updateCollection = (action, state) => { 2 | const newCollection = action.payload 3 | const oldCollection = state.storyCollection || new Map() 4 | const mergedCollection = new Map([...oldCollection, ...newCollection]) 5 | return Object.assign({}, state, { storyCollection: mergedCollection }) 6 | } 7 | 8 | const updateCurrentItems = (keys, state) => { 9 | return Object.assign({}, state, { currentItems: [...keys] }) 10 | } 11 | 12 | const updateComments = ({ itemId, payload }, state) => { 13 | const commentCollection = state.commentCollection 14 | commentCollection.set(itemId, payload) 15 | return Object.assign({}, state, { commentCollection }) 16 | } 17 | 18 | const updateIds = (topStoryIds, state) => { 19 | return Object.assign({}, state, { topStoryIds }) 20 | } 21 | 22 | const updateUser = (user, state) => { 23 | return Object.assign({}, state, { user }) 24 | } 25 | 26 | const updatePollOptions = ({ itemId, payload }, state) => { 27 | state.pollOptionCollection.set(itemId, payload) 28 | return Object.assign({}, state, { pollOptionCollection: state.pollOptionCollection }) 29 | } 30 | 31 | const isLoadingItems = (boolean, state) => { 32 | return Object.assign({}, state, { isLoadingItems: boolean }) 33 | } 34 | 35 | const isLoadingComments = (boolean, state) => { 36 | return Object.assign({}, state, { isLoadingComments: boolean }) 37 | } 38 | 39 | const isLoadingPollOptions = (boolean, state) => { 40 | return Object.assign({}, state, { isLoadingPollOptions: boolean }) 41 | } 42 | 43 | module.exports = { 44 | updateCollection, 45 | updateCurrentItems, 46 | updateComments, 47 | updateIds, 48 | updateUser, 49 | updatePollOptions, 50 | isLoadingItems, 51 | isLoadingComments, 52 | isLoadingPollOptions 53 | } 54 | -------------------------------------------------------------------------------- /src/storage.js: -------------------------------------------------------------------------------- 1 | /*global localStorage*/ 2 | 3 | const save = state => { 4 | state = Object.assign({}, state) 5 | state.fetchingComments = false 6 | state.storyCollection = Array.from(state.storyCollection.entries()) 7 | state.commentCollection = Array.from(state.commentCollection.entries()) 8 | state.pollOptionCollection = Array.from(state.pollOptionCollection.entries()) 9 | localStorage.setItem('state', JSON.stringify(state)) 10 | } 11 | 12 | const get = () => { 13 | let value = localStorage.getItem('state') 14 | if (value === null) return value 15 | value = JSON.parse(value) 16 | value.storyCollection = value.storyCollection.length ? new Map(value.storyCollection) : new Map() 17 | value.commentCollection = value.commentCollection.length ? new Map(value.commentCollection) : new Map() 18 | value.pollOptionCollection = value.pollOptionCollection.length ? new Map(value.pollOptionCollection) : new Map() 19 | return value 20 | } 21 | 22 | module.exports = { save, get } 23 | -------------------------------------------------------------------------------- /src/subscriptions.js: -------------------------------------------------------------------------------- 1 | const api = require('./hackernews-api') 2 | 3 | const topStories = (dispatch, done) => { 4 | api.subscribe((err, ids) => { 5 | if (err) return done(err) 6 | dispatch('updateIds', { payload: ids }, done) 7 | }) 8 | } 9 | 10 | module.exports = [ 11 | topStories 12 | ] 13 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | exports.getDomain = function getDomain (url) { 2 | const matches = url.match(/^https?\:\/\/([^\/?#]+)(?:[\/?#]|$)/i) 3 | const domain = matches && matches[1] 4 | return domain 5 | } 6 | -------------------------------------------------------------------------------- /test/components/comments-item.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const Comment = require('../../src/components/comments/item') 3 | const commentFixture = require('../fixtures/comment') 4 | 5 | Test('CommentItem Component', (t) => { 6 | const test = t.test 7 | t.plan(6) 8 | 9 | test('returns an li element with class name comment', (t) => { 10 | t.plan(2) 11 | const comment = Comment({ item: commentFixture() }) 12 | t.equal(comment.tagName, 'LI') 13 | t.equal(comment.classList[0], 'comment') 14 | }) 15 | 16 | test('returns empty string if comment has been deleted', (t) => { 17 | t.plan(1) 18 | const item = commentFixture() 19 | item.deleted = true 20 | 21 | const comment = Comment({ item }) 22 | t.equal(comment, '') 23 | }) 24 | 25 | test('displays link to comment author', (t) => { 26 | t.plan(1) 27 | const item = commentFixture() 28 | const comment = Comment({ item }) 29 | const authorLink = comment.children[0].children[0] 30 | 31 | t.equal(authorLink.getAttribute('href'), '/user/Foo') 32 | }) 33 | 34 | test('displays time comment was posted', (t) => { 35 | t.plan(1) 36 | const item = commentFixture() 37 | const comment = Comment({ item }) 38 | const postedDate = comment.children[0].children[1] 39 | 40 | t.equal(postedDate.textContent, 'just now') 41 | }) 42 | 43 | test('displays comment body', (t) => { 44 | t.plan(1) 45 | const item = commentFixture() 46 | const comment = Comment({ item }) 47 | const commentBody = comment.children[1].textContent.replace(/\s\s+/g, '') 48 | 49 | t.equal(commentBody, 'Baz') 50 | }) 51 | 52 | test('displays child comments', (t) => { 53 | t.plan(2) 54 | const item = commentFixture() 55 | const comment = Comment({ item }) 56 | const childComment = comment.children[2].children[0] 57 | 58 | t.ok(childComment) 59 | t.equal(childComment.tagName, 'LI') 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/components/comments-list.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const CommentList = require('../../src/components/comments/list') 3 | const commentFixture = require('../fixtures/comment') 4 | 5 | Test('CommentList Component', (t) => { 6 | const test = t.test 7 | t.plan(3) 8 | 9 | test('returns a UL element', (t) => { 10 | t.plan(1) 11 | const comments = CommentList({ comments: [commentFixture()] }) 12 | t.equal(comments.tagName, 'UL') 13 | }) 14 | 15 | test('returns empty string if no comments are given', (t) => { 16 | t.plan(1) 17 | const comments = CommentList({ comments: [] }) 18 | t.equal(comments, '') 19 | }) 20 | 21 | test('displays comments', (t) => { 22 | t.plan(1) 23 | const comments = CommentList({ comments: [commentFixture()] }) 24 | t.equal(comments.children.length, 1) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/components/info-bar.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const InfoBar = require('../../src/components/info-bar') 3 | const itemFixture = require('../fixtures/item') 4 | 5 | Test('InfoBar Component', (t) => { 6 | const test = t.test 7 | t.plan(5) 8 | 9 | test('returns a div element', (t) => { 10 | t.plan(1) 11 | const info = InfoBar({ item: itemFixture() }) 12 | t.equal(info.tagName, 'DIV') 13 | }) 14 | 15 | test('displays only time posted if item.type is not story or poll', (t) => { 16 | t.plan(1) 17 | const item = itemFixture() 18 | item.type = 'job' 19 | 20 | const info = InfoBar({ item }) 21 | t.equal(info.innerText, 'just now') 22 | }) 23 | 24 | test('displays item info', (t) => { 25 | t.plan(1) 26 | const item = itemFixture() 27 | const info = InfoBar({ item }) 28 | const expectedText = '10 points by Foo just now' 29 | 30 | const actualText = info 31 | .children[0] 32 | .textContent.replace(/\s\s+/g, ' ') 33 | .trim() 34 | 35 | t.equal(actualText, expectedText) 36 | }) 37 | 38 | test('displays link to author', (t) => { 39 | t.plan(1) 40 | const item = itemFixture() 41 | const info = InfoBar({ item }) 42 | const linkEl = info.children[0].children[0] 43 | t.equal(linkEl.getAttribute('href'), '/user/Foo') 44 | }) 45 | 46 | test('displays link to comments', (t) => { 47 | t.plan(2) 48 | const item = itemFixture() 49 | const info = InfoBar({ item }) 50 | const linkEl = info.children[1] 51 | t.equal(linkEl.innerText, `${item.descendants} comments`) 52 | t.equal(linkEl.getAttribute('href'), `/item/${item.id}`) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /test/components/item.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const Item = require('../../src/components/item/index') 3 | const itemFixture = require('../fixtures/item') 4 | const pollOptionsFixture = require('../fixtures/poll-options') 5 | 6 | Test('Item Component', (t) => { 7 | const test = t.test 8 | t.plan(6) 9 | 10 | test('returns a DIV element', (t) => { 11 | t.plan(1) 12 | const item = Item({ item: itemFixture() }) 13 | t.equal(item.tagName, 'DIV') 14 | }) 15 | 16 | test('links the title to the item url', (t) => { 17 | t.plan(2) 18 | const item = itemFixture() 19 | const itemEl = Item({ item }) 20 | t.equal(itemEl.children[0].textContent, 'Bar') 21 | t.equal(itemEl.children[0].getAttribute('href'), 'https://test.com') 22 | }) 23 | 24 | test('displays the URL component', (t) => { 25 | t.plan(1) 26 | const item = itemFixture() 27 | const itemEl = Item({ item }) 28 | t.ok(itemEl.querySelector('.domain')) 29 | }) 30 | 31 | test('displays the InfoBar component', (t) => { 32 | t.plan(1) 33 | const item = itemFixture() 34 | const itemEl = Item({ item }) 35 | t.ok(itemEl.querySelector('.InfoBar')) 36 | }) 37 | 38 | test('displays the item text', (t) => { 39 | t.plan(1) 40 | const item = itemFixture() 41 | const itemEl = Item({ item }) 42 | const textEl = itemEl.children[3] 43 | t.equal(textEl.textContent, 'Baz') 44 | }) 45 | 46 | test('displays poll options if item type is poll', (t) => { 47 | t.plan(1) 48 | const item = itemFixture() 49 | const pollOptions = pollOptionsFixture() 50 | item.type = 'poll' 51 | const itemEl = Item({ item, pollOptions }) 52 | const pollOptionsEl = itemEl.children[4] 53 | t.equal(pollOptionsEl.children.length, 2) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /test/components/itemlist-item.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const Item = require('../../src/components/item-list/item') 3 | const itemFixture = require('../fixtures/item') 4 | 5 | Test('ItemList-Item Component', (t) => { 6 | const test = t.test 7 | t.plan(5) 8 | 9 | test('returns a TR element', (t) => { 10 | t.plan(1) 11 | const item = Item({ item: itemFixture(), index: 1 }) 12 | t.equal(item.tagName, 'TR') 13 | }) 14 | 15 | test('displays the given index', (t) => { 16 | t.plan(1) 17 | const item = itemFixture() 18 | const itemEl = Item({ item, index: 1 }) 19 | t.equal(itemEl.children[0].textContent, '1.') 20 | }) 21 | 22 | test('displays title as link to article source', (t) => { 23 | t.plan(2) 24 | const item = itemFixture() 25 | const itemEl = Item({ item }) 26 | const titleEl = itemEl.children[1].children[0] 27 | 28 | t.equal(titleEl.getAttribute('href'), 'https://test.com') 29 | t.equal(titleEl.textContent.replace(/\s\s+/g, ' ').trim(), 'Bar') 30 | }) 31 | 32 | test('displays URL component', (t) => { 33 | t.plan(1) 34 | const item = itemFixture() 35 | const itemEl = Item({ item }) 36 | const urlEl = itemEl.children[1].children[1].children[0] 37 | t.equal(urlEl.classList[0], 'domain') 38 | }) 39 | 40 | test('displays InfoBar component', (t) => { 41 | t.plan(1) 42 | const item = itemFixture() 43 | const itemEl = Item({ item }) 44 | const infoEl = itemEl.children[1].children[2].children[0] 45 | 46 | t.equal(infoEl.classList[0], 'InfoBar') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/components/itemlist-list.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const ItemList = require('../../src/components/item-list/list') 3 | const itemFixture = require('../fixtures/item') 4 | 5 | const listFixture = () => { 6 | return [ itemFixture() ] 7 | } 8 | 9 | Test('ItemListList-List Component', (t) => { 10 | const test = t.test 11 | t.plan(3) 12 | 13 | test('returns a DIV element', (t) => { 14 | t.plan(1) 15 | const listEl = ItemList({ 16 | collection: listFixture(), 17 | pageNumber: 1, 18 | storiesPerPage: 30 19 | }) 20 | t.equal(listEl.tagName, 'DIV') 21 | }) 22 | 23 | test('creates a table row for each list item', (t) => { 24 | t.plan(1) 25 | const listEl = ItemList({ 26 | collection: listFixture(), 27 | pageNumber: 1, 28 | storiesPerPage: 30 29 | }) 30 | t.equal(listEl.children[0].children[0].children.length, 1) 31 | }) 32 | 33 | test('properly calculates item index', (t) => { 34 | t.plan(1) 35 | const listEl = ItemList({ 36 | collection: listFixture(), 37 | pageNumber: 2, 38 | storiesPerPage: 30 39 | }) 40 | const itemEl = listEl.querySelector('.StoryList-item') 41 | const index = itemEl.children[0].textContent 42 | t.equal(index, '31.') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/components/loading.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const Loading = require('../../src/components/loading') 3 | 4 | Test('Loading Component', (t) => { 5 | const test = t.test 6 | t.plan(3) 7 | 8 | test('returns a div element', (t) => { 9 | t.plan(1) 10 | const loading = Loading() 11 | t.equal(loading.tagName, 'DIV') 12 | }) 13 | 14 | test('displays Loading... as default text', (t) => { 15 | t.plan(1) 16 | const loading = Loading() 17 | t.equal(loading.children[0].innerText, 'Loading...') 18 | }) 19 | 20 | test('displays custom text', (t) => { 21 | t.plan(1) 22 | const loading = Loading({ text: 'Foo' }) 23 | t.equal(loading.children[0].innerText, 'Foo') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/components/poll-options.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const PollOptions = require('../../src/components/item/poll-options') 3 | const pollOptionsFixture = require('../fixtures/poll-options') 4 | 5 | Test('PollOptions Component', (t) => { 6 | const test = t.test 7 | t.plan(4) 8 | 9 | test('returns a UL element', (t) => { 10 | t.plan(1) 11 | const pollEl = PollOptions({ pollOptions: pollOptionsFixture() }) 12 | t.equal(pollEl.tagName, 'UL') 13 | }) 14 | 15 | test('displays correct number of options', (t) => { 16 | t.plan(2) 17 | const pollElOne = PollOptions({ pollOptions: pollOptionsFixture() }) 18 | const pollElTwo = PollOptions({ pollOptions: [ pollOptionsFixture().shift() ] }) 19 | t.equal(pollElOne.children.length, 2) 20 | t.equal(pollElTwo.children.length, 1) 21 | }) 22 | 23 | test('displays option text', (t) => { 24 | t.plan(1) 25 | const pollEl = PollOptions({ pollOptions: pollOptionsFixture() }) 26 | t.equal(pollEl.children[0].children[0].textContent, 'Foo') 27 | }) 28 | 29 | test('displays option score', (t) => { 30 | t.plan(1) 31 | const pollEl = PollOptions({ pollOptions: pollOptionsFixture() }) 32 | t.equal(pollEl.children[0].children[1].textContent, '1 points') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/components/url.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const URL = require('../../src/components/url') 3 | 4 | Test('URL Component', (t) => { 5 | const test = t.test 6 | t.plan(4) 7 | 8 | test('returns a span element when a url is passed in', (t) => { 9 | t.plan(1) 10 | const url = URL({ url: 'https://foo.com' }) 11 | t.equal(url.tagName, 'SPAN') 12 | }) 13 | 14 | test('returns a properly formatted url', (t) => { 15 | t.plan(1) 16 | const url = URL({ url: 'https://foo.com' }) 17 | t.equal(url.innerText, '(foo.com)') 18 | }) 19 | 20 | test('returns an empty string if no url is passed in', (t) => { 21 | t.plan(1) 22 | const url = URL() 23 | t.equal(url, '') 24 | }) 25 | 26 | test('returns an empty string if url is self-referential', (t) => { 27 | t.plan(1) 28 | const url = URL({ url: '/item/12345' }) 29 | t.equal(url, '') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/fixtures/comment.js: -------------------------------------------------------------------------------- 1 | const commentFixture = () => { 2 | return { 3 | time: new Date().getTime() / 1000, 4 | by: 'Foo', 5 | id: 1, 6 | text: 'Baz', 7 | children: [ 8 | { 9 | time: new Date().getTime() / 1000, 10 | by: 'Bar', 11 | text: 'Qux', 12 | id: 2 13 | } 14 | ] 15 | } 16 | } 17 | 18 | module.exports = commentFixture 19 | -------------------------------------------------------------------------------- /test/fixtures/item.js: -------------------------------------------------------------------------------- 1 | const itemFixture = () => { 2 | return { 3 | time: new Date().getTime() / 1000, 4 | by: 'Foo', 5 | title: 'Bar', 6 | id: 1, 7 | text: 'Baz', 8 | url: 'https://test.com', 9 | descendants: 2, 10 | type: 'story', 11 | score: 10 12 | } 13 | } 14 | 15 | module.exports = itemFixture 16 | -------------------------------------------------------------------------------- /test/fixtures/poll-options.js: -------------------------------------------------------------------------------- 1 | const optionsFixture = () => { 2 | return [{ 3 | text: 'Foo', 4 | score: 1 5 | }, { 6 | text: 'Bar', 7 | score: 2 8 | }] 9 | } 10 | 11 | module.exports = optionsFixture 12 | -------------------------------------------------------------------------------- /test/pages/home.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const Emitter = require('component-emitter') 3 | const HomePage = require('../../src/pages/home') 4 | const itemFixture = require('../fixtures/item') 5 | const commentFixture = require('../fixtures/comment') 6 | const pollOptionsFixture = require('../fixtures/poll-options') 7 | 8 | const emitter = new Emitter() 9 | 10 | const dispatch = (msg, data) => { 11 | emitter.emit(msg, data) 12 | } 13 | 14 | const homePageState = () => { 15 | const item = itemFixture() 16 | const storyCollection = new Map().set(item.id, item) 17 | const commentCollection = new Map().set(item.id, [ commentFixture() ]) 18 | const pollOptionCollection = new Map().set(item.id, pollOptionsFixture) 19 | const currentItems = [] 20 | const params = { itemId: item.id } 21 | 22 | return { 23 | storyCollection, 24 | commentCollection, 25 | pollOptionCollection, 26 | currentItems, 27 | params, 28 | item 29 | } 30 | } 31 | 32 | Test('Home Page', (t) => { 33 | const test = t.test 34 | t.plan(3) 35 | 36 | test('returns a div element with class HomePage', (t) => { 37 | t.plan(1) 38 | const state = homePageState() 39 | const prevState = {} 40 | const page = HomePage(state, prevState, dispatch) 41 | const itemEl = page.querySelector('.HomePage') 42 | t.equal(itemEl.tagName, 'DIV') 43 | }) 44 | 45 | test('dispatches fetchItemsByPage on load', (t) => { 46 | t.plan(1) 47 | let el 48 | 49 | emitter.on('fetchItemsByPage', (pageNumber) => { 50 | emitter.off() 51 | setTimeout(() => { 52 | el.parentElement.removeChild(el) 53 | }) 54 | t.ok(true) 55 | }) 56 | 57 | const state = homePageState() 58 | const prevState = {} 59 | state.storyCollection = new Map() 60 | el = HomePage(state, prevState, dispatch) 61 | document.body.appendChild(el) 62 | }) 63 | 64 | test('dispatches fetchItemsByPage on load with correct page number', (t) => { 65 | t.plan(1) 66 | let el 67 | 68 | emitter.on('fetchItemsByPage', (pageNumber) => { 69 | emitter.off() 70 | setTimeout(() => { 71 | el.parentElement.removeChild(el) 72 | t.equal(pageNumber, 2) 73 | }) 74 | }) 75 | 76 | const state = homePageState() 77 | const prevState = {} 78 | state.storyCollection = new Map() 79 | state.params = { pageNumber: '2' } 80 | el = HomePage(state, prevState, dispatch) 81 | document.body.appendChild(el) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /test/pages/item.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const Emitter = require('component-emitter') 3 | const ItemPage = require('../../src/pages/item') 4 | const itemFixture = require('../fixtures/item') 5 | const commentFixture = require('../fixtures/comment') 6 | const pollOptionsFixture = require('../fixtures/poll-options') 7 | 8 | const emitter = new Emitter() 9 | 10 | const dispatch = (msg, data) => { 11 | emitter.emit(msg, data) 12 | } 13 | 14 | const itemPageState = () => { 15 | const item = itemFixture() 16 | const storyCollection = new Map().set(item.id, item) 17 | const commentCollection = new Map().set(item.id, [ commentFixture() ]) 18 | const pollOptionCollection = new Map().set(item.id, pollOptionsFixture) 19 | const params = { itemId: item.id } 20 | 21 | return { 22 | storyCollection, 23 | commentCollection, 24 | pollOptionCollection, 25 | params, 26 | item 27 | } 28 | } 29 | 30 | Test('Item Page', (t) => { 31 | const test = t.test 32 | t.plan(8) 33 | 34 | test('returns a div element with class Item', (t) => { 35 | t.plan(1) 36 | const state = itemPageState() 37 | const prevState = {} 38 | const page = ItemPage(state, prevState, dispatch) 39 | const itemEl = page.querySelector('.ItemPage') 40 | t.equal(itemEl.tagName, 'DIV') 41 | }) 42 | 43 | test('dispatches fetchItems if item does not exist in the collection', (t) => { 44 | t.plan(1) 45 | 46 | emitter.on('fetchItems', (itemId) => { 47 | emitter.off() 48 | t.equal(itemId, 1) 49 | }) 50 | 51 | const state = itemPageState() 52 | const prevState = {} 53 | state.storyCollection = new Map() 54 | 55 | ItemPage(state, prevState, dispatch) 56 | }) 57 | 58 | test('displays Loading component if item does not exist in the collection', (t) => { 59 | t.plan(2) 60 | 61 | const state = itemPageState() 62 | const prevState = {} 63 | state.storyCollection = new Map() 64 | 65 | const page = ItemPage(state, prevState, dispatch) 66 | const loadingEl = page.querySelector('.Loading') 67 | 68 | t.ok(loadingEl) 69 | t.equal(loadingEl.children[0].innerText, 'Loading story...') 70 | }) 71 | 72 | test('dispatches fetchPollOptions item type is poll & options do not exist in the collection', (t) => { 73 | t.plan(1) 74 | 75 | emitter.on('fetchPollOptions', (itemId) => { 76 | emitter.off() 77 | t.equal(itemId, 1) 78 | }) 79 | 80 | const state = itemPageState() 81 | const prevState = {} 82 | state.item.type = 'poll' 83 | state.pollOptionCollection = new Map() 84 | 85 | ItemPage(state, prevState, dispatch) 86 | }) 87 | 88 | test('displays Loading component if item type is poll & options do not exist in the collection', (t) => { 89 | t.plan(2) 90 | 91 | const state = itemPageState() 92 | const prevState = {} 93 | state.item.type = 'poll' 94 | state.pollOptionCollection = new Map() 95 | 96 | const page = ItemPage(state, prevState, dispatch) 97 | const loadingEl = page.querySelector('.Loading') 98 | 99 | t.ok(loadingEl) 100 | t.equal(loadingEl.children[0].innerText, 'Loading poll...') 101 | }) 102 | 103 | test('displays Item component', (t) => { 104 | t.plan(1) 105 | 106 | const state = itemPageState() 107 | const prevState = {} 108 | const page = ItemPage(state, prevState, dispatch) 109 | const storyEl = page.querySelector('.Item') 110 | t.ok(storyEl) 111 | }) 112 | 113 | test('displays Comments component', (t) => { 114 | t.plan(1) 115 | 116 | const state = itemPageState() 117 | const prevState = {} 118 | const page = ItemPage(state, prevState, dispatch) 119 | const commentEl = page.querySelector('.CommentList') 120 | 121 | t.ok(commentEl) 122 | }) 123 | 124 | test('displays Loading component if comments do not exist in collection', (t) => { 125 | t.plan(2) 126 | 127 | const state = itemPageState() 128 | const prevState = {} 129 | state.commentCollection = new Map() 130 | 131 | const page = ItemPage(state, prevState, dispatch) 132 | const loadingEl = page.querySelector('.Loading') 133 | 134 | t.ok(loadingEl) 135 | t.equal(loadingEl.children[0].innerText, 'Loading comments...') 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /test/pages/user.js: -------------------------------------------------------------------------------- 1 | const Test = require('tape') 2 | const Emitter = require('component-emitter') 3 | const UserPage = require('../../src/pages/user') 4 | const emitter = new Emitter() 5 | 6 | const userFixture = () => { 7 | return { 8 | created: new Date().getTime() / 1000, 9 | karma: 100, 10 | id: 'Foo', 11 | about: 'Baz' 12 | } 13 | } 14 | 15 | function dispatch (msg, data) { 16 | emitter.emit(msg, data) 17 | } 18 | 19 | Test('User Page', (t) => { 20 | const test = t.test 21 | t.plan(9) 22 | 23 | test('returns a div element', (t) => { 24 | t.plan(1) 25 | const params = { userId: 'Foo' } 26 | const user = userFixture() 27 | const page = UserPage({ user, params, dispatch }) 28 | t.equal(page.tagName, 'DIV') 29 | }) 30 | 31 | test('dispatches fetchUser if user is not supplied via state', (t) => { 32 | t.plan(1) 33 | 34 | emitter.on('fetchUser', (userId) => { 35 | emitter.off() 36 | t.equal(userId, 'Foo') 37 | }) 38 | 39 | const params = { userId: 'Foo' } 40 | UserPage({ params }, {}, dispatch) 41 | }) 42 | 43 | test('dispatches fetchUser if userId parameter does not match current user state', (t) => { 44 | t.plan(2) 45 | 46 | emitter.on('fetchUser', (userId) => { 47 | emitter.off() 48 | t.equal(userId, 'Bar') 49 | }) 50 | 51 | const user = userFixture() 52 | const params = { userId: 'Bar' } 53 | t.equal(user.id, 'Foo') 54 | UserPage({ params, user }, {}, dispatch) 55 | }) 56 | 57 | test('displays the user id', (t) => { 58 | t.plan(1) 59 | const params = { userId: 'Foo' } 60 | const user = userFixture() 61 | const page = UserPage({ user, params, dispatch }) 62 | const el = page.querySelector('.User') 63 | t.equal(el.children[0].textContent, 'Foo') 64 | }) 65 | 66 | test('displays link to user submissions', (t) => { 67 | t.plan(2) 68 | const params = { userId: 'Foo' } 69 | const user = userFixture() 70 | const page = UserPage({ user, params }) 71 | const el = page.querySelector('.User') 72 | const submissionsLink = el.children[1].children[0] 73 | t.equal(submissionsLink.textContent.trim(), 'submissions') 74 | t.equal(submissionsLink.getAttribute('href'), `https://news.ycombinator.com/submitted?id=${user.id}`) 75 | }) 76 | 77 | test('displays link to user comments', (t) => { 78 | t.plan(2) 79 | const params = { userId: 'Foo' } 80 | const user = userFixture() 81 | const page = UserPage({ user, params }) 82 | const el = page.querySelector('.User') 83 | const commentsLink = el.children[1].children[1] 84 | t.equal(commentsLink.textContent.trim(), 'comments') 85 | t.equal(commentsLink.getAttribute('href'), `https://news.ycombinator.com/comments?id=${user.id}`) 86 | }) 87 | 88 | test('displays user created date', (t) => { 89 | t.plan(1) 90 | const params = { userId: 'Foo' } 91 | const user = userFixture() 92 | const page = UserPage({ user, params }) 93 | const el = page.querySelector('.User') 94 | const createdEl = el.children[2].children[0].children[1] 95 | t.equal(createdEl.textContent, 'just now') 96 | }) 97 | 98 | test('displays user karma', (t) => { 99 | t.plan(1) 100 | const params = { userId: 'Foo' } 101 | const user = userFixture() 102 | const page = UserPage({ user, params }) 103 | const el = page.querySelector('.User') 104 | const karmaEl = el.children[2].children[1].children[1] 105 | t.equal(karmaEl.textContent, '100') 106 | }) 107 | 108 | test('displays about text', (t) => { 109 | t.plan(1) 110 | const params = { userId: 'Foo' } 111 | const user = userFixture() 112 | const page = UserPage({ user, params }) 113 | const el = page.querySelector('.User') 114 | const aboutEl = el.children[3].children[1] 115 | t.equal(aboutEl.textContent, 'Baz') 116 | }) 117 | }) 118 | --------------------------------------------------------------------------------