├── .babelrc ├── .bundle └── config ├── .circleci └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .firebaserc ├── .gitignore ├── .nvmrc ├── .scss-lint.yml ├── .storybook ├── .babelrc ├── addons.js ├── config.js ├── storybook.scss └── webpack.config.js ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── app ├── analytics │ ├── constants.js │ ├── createGaProxy.js │ ├── google-analytics.js │ └── index.js ├── assets │ └── images │ │ └── hnpwa-logo.png ├── client.jsx ├── components │ ├── AppShell.jsx │ ├── HackerNewsItem │ │ ├── HackerNewsItem.scss │ │ ├── HackerNewsItem.spec.jsx │ │ ├── HackerNewsItem.stories.jsx │ │ ├── __snapshots__ │ │ │ └── HackerNewsItem.spec.jsx.snap │ │ └── index.jsx │ ├── HackerNewsList │ │ ├── HackerNewsList.scss │ │ ├── HackerNewsList.spec.jsx │ │ ├── HackerNewsList.stories.jsx │ │ ├── __snapshots__ │ │ │ └── HackerNewsList.spec.jsx.snap │ │ └── index.jsx │ ├── HackerNewsListItem │ │ ├── HackerNewsListItem.scss │ │ ├── HackerNewsListItem.spec.jsx │ │ ├── HackerNewsListItem.stories.jsx │ │ ├── __snapshots__ │ │ │ └── HackerNewsListItem.spec.jsx.snap │ │ └── index.jsx │ ├── HackerNewsUser │ │ ├── HackerNewsUser.scss │ │ ├── HackerNewsUser.spec.jsx │ │ ├── __snapshots__ │ │ │ └── HackerNewsUser.spec.jsx.snap │ │ └── index.jsx │ ├── Header │ │ ├── Header.scss │ │ ├── Header.spec.jsx │ │ ├── Header.stories.jsx │ │ ├── __snapshots__ │ │ │ └── Header.spec.jsx.snap │ │ └── index.jsx │ ├── LoadingIndicator │ │ ├── LoadingIndicator.scss │ │ ├── LoadingIndicator.spec.jsx │ │ ├── LoadingIndicator.stories.jsx │ │ ├── __snapshots__ │ │ │ └── LoadingIndicator.spec.jsx.snap │ │ └── index.jsx │ ├── LocationPagination │ │ ├── LocationPagination.spec.jsx │ │ ├── __snapshots__ │ │ │ └── LocationPagination.spec.jsx.snap │ │ └── index.jsx │ └── Pagination │ │ ├── Pagination.scss │ │ ├── Pagination.spec.jsx │ │ ├── Pagination.stories.jsx │ │ ├── __snapshots__ │ │ └── Pagination.spec.jsx.snap │ │ └── index.jsx ├── containers │ ├── HackerNewsComment │ │ ├── HackerNewsComment.scss │ │ ├── HackerNewsComment.spec.jsx │ │ ├── HackerNewsComment.stories.jsx │ │ ├── __snapshots__ │ │ │ └── HackerNewsComment.spec.jsx.snap │ │ └── index.jsx │ ├── HackerNewsItemContainer.jsx │ ├── HackerNewsListContainer.jsx │ ├── HackerNewsUserContainer.jsx │ └── LocationPaginationContainer.jsx ├── index.ejs ├── pages │ ├── Feed │ │ └── index.jsx │ ├── Item │ │ └── index.jsx │ ├── User │ │ └── index.jsx │ └── index.js ├── scss │ ├── base │ │ ├── mixins │ │ │ └── _mixins.scss │ │ ├── typography.scss │ │ └── variables │ │ │ ├── _colors.scss │ │ │ └── _sizes.scss │ └── style.scss ├── server.jsx └── store │ ├── __tests__ │ ├── actions.spec.js │ ├── flattenComments.spec.js │ ├── reducers │ │ ├── byId.spec.js │ │ ├── comments │ │ │ ├── byId.spec.js │ │ │ └── posts.spec.js │ │ ├── currentPage.spec.js │ │ ├── isFetching.spec.js │ │ ├── items.spec.js │ │ └── user.spec.js │ └── selectors.spec.js │ ├── actionTypes.js │ ├── actions.js │ ├── byId │ └── index.js │ ├── comments │ ├── byId.js │ ├── index.js │ └── posts.js │ ├── configureStore.js │ ├── currentPage │ └── index.js │ ├── flattenComments.js │ ├── history.js │ ├── isFetching │ └── index.js │ ├── items │ └── index.js │ ├── rootReducer.js │ ├── selectors.js │ └── user │ └── index.js ├── devServer.js ├── firebase.json ├── functions ├── index.js ├── package.json └── yarn.lock ├── jest.config.json ├── package.json ├── paths.js ├── postcss.config.js ├── public ├── favicon.ico ├── icon-144x144.png ├── icon-192x192.png ├── icon-512x512.png ├── logo.png ├── manifest.json ├── og-image.png └── service-worker.js ├── test ├── __mocks__ │ ├── fileMock.js │ └── styleMock.js ├── setup.js └── shim.js ├── webpack.base.config.js ├── webpack.dev.config.js ├── webpack.prod.config.js ├── webpack.server.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["es2015", { "modules": false }], "react", "stage-2"], 3 | "plugins": [ 4 | "react-hot-loader/babel", 5 | "syntax-dynamic-import", 6 | [ 7 | "transform-imports", { 8 | "react-router-dom": { 9 | "transform": "react-router-dom/es/${member}", 10 | "preventFullImport": true 11 | }, 12 | "lodash": { 13 | "transform": "lodash/${member}", 14 | "preventFullImport": true 15 | } 16 | } 17 | ] 18 | ], 19 | "env": { 20 | "test": { 21 | "plugins": [ 22 | "dynamic-import-node", 23 | "transform-es2015-modules-commonjs" 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "bundler" 3 | BUNDLE_DISABLE_SHARED_GEMS: "true" 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/hnpwa-react 3 | docker: 4 | - image: circleci/node:8.5.0 5 | environment: 6 | TZ: "/usr/share/zoneinfo/Asia/Seoul" 7 | 8 | version: 2 9 | 10 | jobs: 11 | checkout: 12 | <<: *defaults 13 | steps: 14 | - checkout 15 | - save_cache: 16 | key: repository-{{ .Environment.CIRCLE_SHA1 }} 17 | paths: 18 | - ~/hnpwa-react 19 | 20 | dependency: 21 | <<: *defaults 22 | steps: 23 | - restore_cache: 24 | key: repository-{{ .Environment.CIRCLE_SHA1 }} 25 | - restore_cache: 26 | key: root-npm-dependency-{{ .Branch }}-{{ checksum "yarn.lock" }} 27 | - run: 28 | name: Install Root Dependency 29 | command: yarn install 30 | - save_cache: 31 | key: root-npm-dependency-{{ .Branch }}-{{ checksum "yarn.lock" }} 32 | paths: 33 | - ./node_modules 34 | - ~/.yarn-cache 35 | - restore_cache: 36 | key: function-npm-dependency-{{ .Branch }}-{{ checksum "functions/yarn.lock" }} 37 | - run: 38 | name: Install GCP Function Dependency 39 | command: cd functions && yarn install && cd .. 40 | - save_cache: 41 | key: function-npm-dependency-{{ .Branch }}-{{ checksum "functions/yarn.lock" }} 42 | paths: 43 | - ./functions/node_modules 44 | 45 | test: 46 | <<: *defaults 47 | steps: 48 | - restore_cache: 49 | key: repository-{{ .Environment.CIRCLE_SHA1 }} 50 | - restore_cache: 51 | key: root-npm-dependency-{{ .Branch }}-{{ checksum "yarn.lock" }} 52 | - run: 53 | name: Test 54 | command: yarn test:coverage -- --maxWorkers=2 55 | - store_artifacts: 56 | path: coverage 57 | destination: coverage 58 | 59 | build: 60 | <<: *defaults 61 | steps: 62 | - restore_cache: 63 | key: repository-{{ .Environment.CIRCLE_SHA1 }} 64 | - restore_cache: 65 | key: root-npm-dependency-{{ .Branch }}-{{ checksum "yarn.lock" }} 66 | - restore_cache: 67 | key: function-npm-dependency-{{ .Branch }}-{{ checksum "functions/yarn.lock" }} 68 | - run: 69 | name: Build 70 | command: yarn build 71 | - save_cache: 72 | key: build-{{ .Environment.CIRCLE_SHA1 }} 73 | paths: 74 | - ~/hnpwa-react/build 75 | - ~/hnpwa-react/functions/server.bundle.js 76 | - ~/hnpwa-react/functions/views 77 | 78 | deploy-stage: 79 | <<: *defaults 80 | steps: 81 | - restore_cache: 82 | key: repository-{{ .Environment.CIRCLE_SHA1 }} 83 | - restore_cache: 84 | key: root-npm-dependency-{{ .Branch }}-{{ checksum "yarn.lock" }} 85 | - restore_cache: 86 | key: function-npm-dependency-{{ .Branch }}-{{ checksum "functions/yarn.lock" }} 87 | - restore_cache: 88 | key: build-{{ .Environment.CIRCLE_SHA1 }} 89 | - run: 90 | name: Deploy to firebase 91 | command: yarn deploy:stage 92 | 93 | deploy-production: 94 | <<: *defaults 95 | steps: 96 | - restore_cache: 97 | key: repository-{{ .Environment.CIRCLE_SHA1 }} 98 | - restore_cache: 99 | key: root-npm-dependency-{{ .Branch }}-{{ checksum "yarn.lock" }} 100 | - restore_cache: 101 | key: function-npm-dependency-{{ .Branch }}-{{ checksum "functions/yarn.lock" }} 102 | - restore_cache: 103 | key: build-{{ .Environment.CIRCLE_SHA1 }} 104 | - run: 105 | name: Deploy to firebase 106 | command: yarn deploy 107 | 108 | workflows: 109 | version: 2 110 | test-and-build-deploy: 111 | jobs: 112 | - checkout: 113 | filters: 114 | tags: 115 | only: /.*/ 116 | - dependency: 117 | requires: 118 | - checkout 119 | filters: 120 | tags: 121 | only: /.*/ 122 | - test: 123 | requires: 124 | - dependency 125 | filters: 126 | tags: 127 | only: /.*/ 128 | - build: 129 | requires: 130 | - test 131 | filters: 132 | branches: 133 | only: master 134 | tags: 135 | only: /.*/ 136 | - deploy-stage: 137 | requires: 138 | - build 139 | filters: 140 | branches: 141 | only: master 142 | tags: 143 | ignore: /.*/ 144 | - deploy-production: 145 | requires: 146 | - build 147 | filters: 148 | branches: 149 | ignore: /.*/ 150 | tags: 151 | only: /.*/ 152 | 153 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | build/ 4 | bundler/ 5 | functions/ 6 | scripts/ 7 | 8 | devServer.js 9 | generate-sw.js 10 | webpack.*.config.js 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "plugins": ["react", "jsx-a11y", "import"], 4 | "settings": { 5 | "import/resolver": { 6 | "eslint-import-resolver-webpack": { 7 | "config": "webpack.base.config.js" 8 | } 9 | } 10 | }, 11 | "rules": { 12 | "no-underscore-dangle": [ 13 | "error", 14 | { 15 | "allow": ["__PRELOADED_STATE__", "__REDUX_DEVTOOLS_EXTENSION_COMPOSE__"] 16 | } 17 | ], 18 | "jsx-a11y/href-no-hash": 0 19 | }, 20 | "globals": { 21 | "__DEV__": false, 22 | "__PROD__": false, 23 | "__SERVER__": false 24 | }, 25 | "env": { 26 | "browser": true 27 | }, 28 | "parser": "babel-eslint", 29 | "parserOptions": { 30 | "allowImportExportEverywhere": true 31 | }, 32 | "overrides": [ 33 | { 34 | "files": ["*.stories.jsx"], 35 | "rules": { 36 | "import/no-extraneous-dependencies": [2, { 37 | "devDependencies": true 38 | }] 39 | } 40 | }, 41 | { 42 | "files": ["*.spec.js", "*.spec.jsx"], 43 | "plugins": ["react", "jsx-a11y", "import", "jest"], 44 | "env": { 45 | "jest": true 46 | }, 47 | "rules": { 48 | "import/no-extraneous-dependencies": [2, { 49 | "devDependencies": true 50 | }] 51 | } 52 | }, 53 | { 54 | "files": ["service-worker.js"], 55 | "env": { 56 | "serviceworker": true 57 | }, 58 | "rules": { 59 | "comma-dangle": 0 60 | } 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "hnpwa-react" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | bundler/ 3 | coverage/ 4 | functions/node_modules/ 5 | functions/server.bundle.js 6 | functions/views/ 7 | gh-pages/ 8 | node_modules/ 9 | stats.json 10 | .idea/ 11 | .env 12 | 13 | *.log 14 | npm-debug.log* 15 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8.5.0 2 | -------------------------------------------------------------------------------- /.scss-lint.yml: -------------------------------------------------------------------------------- 1 | severity: error 2 | 3 | linters: 4 | 5 | BorderZero: 6 | enabled: true 7 | convention: zero 8 | 9 | BemDepth: 10 | enabled: true 11 | 12 | DeclarationOrder: 13 | enabled: false 14 | 15 | ExtendDirective: 16 | enabled: true 17 | 18 | LeadingZero: 19 | enabled: false 20 | 21 | NameFormat: 22 | enabled: true 23 | 24 | PrivateNamingConvention: 25 | enabled: true 26 | prefix: _ 27 | 28 | PropertySortOrder: 29 | enabled: true 30 | 31 | QualifyingElement: 32 | enabled: false 33 | 34 | SelectorFormat: 35 | enabled: true 36 | convention: hyphenated_BEM 37 | class_convention: ^(?!js-).* 38 | class_convention_explanation: should not be written in the form js-* 39 | 40 | SingleLinePerProperty: 41 | enabled: true 42 | allow_single_line_rule_sets: false 43 | 44 | StringQuotes: 45 | enabled: true 46 | style: double_quotes 47 | -------------------------------------------------------------------------------- /.storybook/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-2"] 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-knobs/register'; 3 | import '@storybook/addon-options/register'; 4 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure, setAddon } from '@storybook/react'; 2 | import infoAddon from '@storybook/addon-info'; 3 | import { setOptions } from '@storybook/addon-options'; 4 | 5 | import '../app/scss/style.scss'; 6 | import './storybook.scss'; 7 | 8 | setAddon(infoAddon); 9 | 10 | const req = require.context('../app', true, /\.stories\.jsx$/); 11 | 12 | 13 | setOptions({ 14 | name: 'HNPWA with React', 15 | url: 'https://github.com/taehwanno/hnpwa-react', 16 | }); 17 | 18 | function loadStories() { 19 | req.keys().forEach(path => req(path)); 20 | } 21 | 22 | configure(loadStories, module); 23 | -------------------------------------------------------------------------------- /.storybook/storybook.scss: -------------------------------------------------------------------------------- 1 | // scss-lint:disable all 2 | body { 3 | background-color: rgba(0, 0, 0, 0.05); 4 | background-image: repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(0, 0, 0, 0.2) 1px, transparent 8px), repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(0, 0, 0, 0.2) 1px, transparent 8px); 5 | background-size: 8px 8px; 6 | display: block; 7 | margin: 8px; 8 | } 9 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const paths = require('../paths'); 4 | 5 | module.exports = { 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.stories.jsx?$/, 10 | include: [paths.app], 11 | exclude: /node_modules/, 12 | enforce: 'pre', 13 | loader: 'eslint-loader', 14 | }, 15 | { 16 | test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|cur)$/, 17 | loader: 'file-loader', 18 | options: { 19 | name: '[path][name].[ext]', 20 | }, 21 | }, 22 | { 23 | test: /\.scss$/, 24 | use: [ 25 | 'style-loader', 26 | 'css-loader?sourceMap', 27 | 'postcss-loader?sourceMap', 28 | 'resolve-url-loader', 29 | 'sass-loader?sourceMap', 30 | { 31 | loader: 'sass-resources-loader', 32 | options: { 33 | resources: [ 34 | './app/scss/base/**/_*.scss', 35 | ], 36 | }, 37 | }, 38 | ] 39 | }, 40 | ], 41 | }, 42 | resolve: { 43 | alias: { 44 | assets: paths.assets, 45 | components: paths.components, 46 | containers: paths.containers, 47 | pages: paths.pages, 48 | store: paths.store, 49 | }, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem 'sass', '~> 3.4', '>= 3.4.23' 3 | gem 'rake', '~> 12.0' 4 | gem 'scss_lint', '~> 0.51.0' 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | rake (12.0.0) 5 | sass (3.4.23) 6 | scss_lint (0.51.0) 7 | rake (>= 0.9, < 13) 8 | sass (~> 3.4.20) 9 | 10 | PLATFORMS 11 | ruby 12 | 13 | DEPENDENCIES 14 | rake (~> 12.0) 15 | sass (~> 3.4, >= 3.4.23) 16 | scss_lint (~> 0.51.0) 17 | 18 | BUNDLED WITH 19 | 1.13.7 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Taehwan, No 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HNPWA with React [![Build Status](https://circleci.com/gh/taehwanno/hnpwa-react/tree/master.svg?style=shield&circle-token=3f589df40a8f9d6303dee73907fbf91f9c09cc38)](https://circleci.com/gh/taehwanno/hnpwa-react/tree/master) 2 | 3 | > Hacker News readers as Progressive Web Apps with React, React Router, Redux, Immutable.js 4 | 5 | Live Demo: https://hnpwa-react.firebaseapp.com/ 6 | 7 |
8 | 9 |
10 | 11 | # Features 12 | 13 | - Framework / UI libraries: React, React Router 14 | - State Management: Redux, Immutable.js 15 | - Module Bundling: Webpack 16 | - Service Worker 17 | - Application Shell 18 | - Data caching with [Workbox](https://workboxjs.org/) 19 | - Offline Google Analytics with [workbox-google-analytics](https://workboxjs.org/reference-docs/latest/module-workbox-google-analytics.html) 20 | - Performance Patterns 21 | - Client-side state & DOM hydration 22 | - Server-side data pre-fetching 23 | - Preload / Prefetch resources 24 | - Hosting: Firebase 25 | - Server Side Rendering with Google Cloud Functions 26 | 27 | # Prerequisites 28 | 29 | - node `v8.5.0` 30 | - [yarn](https://yarnpkg.com/lang/en/) 31 | - [bundler](http://bundler.io/) 32 | 33 | ```bash 34 | $ git clone https://github.com/taehwanno/hnpwa-react.git 35 | $ cd hnpwa-react 36 | $ yarn install 37 | $ bundle install 38 | $ cd functions && yarn install && cd .. 39 | ``` 40 | 41 | # Scripts 42 | 43 | ```bash 44 | # Run dev server at 8080 port 45 | $ yarn start 46 | 47 | # Analyze bundle with webpack-bundle-analyzer 48 | $ yarn analyze 49 | $ yarn analyze:cache 50 | 51 | # Lint with eslint, scss-lint 52 | $ yarn lint 53 | $ yarn lint:js 54 | $ yarn lint:scss 55 | 56 | # Test 57 | $ yarn test 58 | $ yarn test:watch 59 | $ yarn test:coverage 60 | 61 | # Build for client, server bundle 62 | $ yarn build 63 | $ yarn build:client 64 | $ yarn build:server 65 | 66 | # Run storybook at 9001 port 67 | $ yarn storybook 68 | ``` 69 | 70 | # Storybook 71 | 72 | https://taehwanno.github.io/hnpwa-react/ 73 | 74 | # License 75 | 76 | MIT © [Taehwan, No](https://github.com/taehwanno) 77 | -------------------------------------------------------------------------------- /app/analytics/constants.js: -------------------------------------------------------------------------------- 1 | export const TRACKING_VERSION = '1'; 2 | 3 | export const ALL_TRACKERS = [ 4 | { name: 'prod', trackingId: 'UA-107782050-1' }, 5 | { name: 'test', trackingId: 'UA-107782050-2' }, 6 | ]; 7 | 8 | export const PROD_TRACKERS = ALL_TRACKERS.filter(({ name }) => /prod/.test(name)); 9 | export const TEST_TRACKERS = ALL_TRACKERS.filter(({ name }) => /test/.test(name)); 10 | 11 | export const NULL_VALUE = '(not set)'; 12 | export const CONNECTION_STATUE_DEFAULT_VALUE = 'online'; 13 | 14 | export const DIMENSIONS = { 15 | TRACKING_VERSION: 'dimension1', 16 | CLIENT_ID: 'dimension2', 17 | WINDOW_ID: 'dimension3', 18 | HIT_ID: 'dimension4', 19 | HIT_TIME: 'dimension5', 20 | HIT_TYPE: 'dimension6', 21 | HIT_SOURCE: 'dimension7', 22 | VISIBILITY_STATE: 'dimension8', 23 | URL_QUERY_PARAMS: 'dimension9', 24 | CONNECTION_STATUS: 'dimension10', 25 | }; 26 | 27 | export const METRICS = { 28 | RESPONSE_END_TIME: 'metric1', 29 | DOM_LOAD_TIME: 'metric2', 30 | WINDOW_LOAD_TIME: 'metric3', 31 | PAGE_VISIBLE: 'metric4', 32 | MAX_SCROLL_PERCENTAGE: 'metric5', 33 | PAGE_LOADS: 'metric6', 34 | CONNECTION_ELAPSED_TIME: 'metric7', 35 | }; 36 | -------------------------------------------------------------------------------- /app/analytics/createGaProxy.js: -------------------------------------------------------------------------------- 1 | /* global ga */ 2 | 3 | /** 4 | * Creates a ga() proxy function that calls commands on all passed trackers. 5 | * @param {!Array} trackers an array or objects containing the `name` and 6 | * `trackingId` fields. 7 | * @return {!Function} The proxied ga() function. 8 | */ 9 | export default function createGaProxy(trackers) { 10 | return function gaProxy(command, ...args) { 11 | // eslint-disable-next-line 12 | for (const { name } of trackers) { 13 | if (typeof command === 'function') { 14 | ga(() => { 15 | command(ga.getByName(name)); 16 | }); 17 | } else { 18 | ga(`${name}.${command}`, ...args); 19 | } 20 | } 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /app/analytics/google-analytics.js: -------------------------------------------------------------------------------- 1 | import 'autotrack/lib/plugins/clean-url-tracker'; 2 | import 'autotrack/lib/plugins/max-scroll-tracker'; 3 | import 'autotrack/lib/plugins/outbound-link-tracker'; 4 | import 'autotrack/lib/plugins/page-visibility-tracker'; 5 | import 'autotrack/lib/plugins/url-change-tracker'; 6 | import { v4 } from 'uuid'; 7 | 8 | import createGaProxy from './createGaProxy'; 9 | import { 10 | TRACKING_VERSION, 11 | ALL_TRACKERS, 12 | PROD_TRACKERS, 13 | TEST_TRACKERS, 14 | NULL_VALUE, 15 | CONNECTION_STATUE_DEFAULT_VALUE, 16 | DIMENSIONS, 17 | METRICS, 18 | } from './constants'; 19 | 20 | /* global ga */ 21 | 22 | 23 | /** 24 | * Command queue proxies 25 | * (exported so they can be called by other modules if needed). 26 | */ 27 | export const gaAll = createGaProxy(ALL_TRACKERS); 28 | export const gaProd = createGaProxy(PROD_TRACKERS); 29 | export const gaTest = createGaProxy(TEST_TRACKERS); 30 | 31 | 32 | /** 33 | * Creates the trackers and sets the default transport and tracking 34 | * version fields. In non-production environments it also logs hits. 35 | */ 36 | const createTrackers = () => { 37 | // eslint-disable-next-line 38 | for (const tracker of ALL_TRACKERS) { 39 | window.ga('create', tracker.trackingId, 'auto', tracker.name); 40 | } 41 | 42 | // Ensures all hits are sent via `navigator.sendBeacon()`. 43 | gaAll('set', 'transport', 'beacon'); 44 | }; 45 | 46 | /** 47 | * Tracks a JavaScript error with optional fields object overrides. 48 | * This function is exported so it can be used in other parts of the codebase. 49 | * E.g.: 50 | * 51 | * `fetch('/api.json').catch(trackError);` 52 | * 53 | * @param {(Error|Object)=} err 54 | * @param {Object=} fieldsObj 55 | */ 56 | export const trackError = (err = {}, fieldsObj = {}) => { 57 | gaAll('send', 'event', Object.assign({ 58 | eventCategory: 'Error', 59 | eventAction: err.name || '(no error name)', 60 | eventLabel: `${err.message}\n${err.stack || '(no stack trace)'}`, 61 | nonInteraction: true, 62 | }, fieldsObj)); 63 | }; 64 | 65 | 66 | /** 67 | * Tracks any errors that may have occured on the page prior to analytics being 68 | * initialized, then adds an event handler to track future errors. 69 | */ 70 | const trackErrors = () => { // eslint-disable-line 71 | // Errors that have occurred prior to this script running are stored on 72 | // `window.__e.q`, as specified in `index.html`. 73 | // eslint-disable-next-line 74 | const loadErrorEvents = (window.__e && window.__e.q) || []; 75 | 76 | const trackErrorEvent = (event) => { 77 | // Use a different eventCategory for uncaught errors. 78 | const fieldsObj = { eventCategory: 'Uncaught Error' }; 79 | 80 | // Some browsers don't have an error property, so we fake it. 81 | const err = event.error || { 82 | message: `${event.message} (${event.lineno}:${event.colno})`, 83 | }; 84 | 85 | trackError(err, fieldsObj); 86 | }; 87 | 88 | // Replay any stored load error events. 89 | for (const event of loadErrorEvents) { // eslint-disable-line 90 | trackErrorEvent(event); 91 | } 92 | 93 | // Add a new listener to track event immediately. 94 | window.addEventListener('error', trackErrorEvent); 95 | }; 96 | 97 | /** 98 | * Accepts a custom dimension or metric and returns it's numerical index. 99 | * @param {string} definition The definition string (e.g. 'dimension1'). 100 | * @return {number} The definition index. 101 | */ 102 | const getDefinitionIndex = definition => +/\d+$/.exec(definition)[0]; 103 | 104 | /** 105 | * Sets a default dimension value for all custom dimensions on all trackers. 106 | */ 107 | const trackCustomDimensions = () => { 108 | // Sets a default dimension value for all custom dimensions to ensure 109 | // that every dimension in every hit has *some* value. This is necessary 110 | // because Google Analytics will drop rows with empty dimension values 111 | // in your reports. 112 | Object.keys(DIMENSIONS).forEach((key) => { 113 | gaAll('set', DIMENSIONS[key], NULL_VALUE); 114 | }); 115 | 116 | gaAll('set', DIMENSIONS.CONNECTION_STATUS, CONNECTION_STATUE_DEFAULT_VALUE); 117 | 118 | // Adds tracking of dimensions known at page load time. 119 | gaAll((tracker) => { 120 | tracker.set({ 121 | [DIMENSIONS.TRACKING_VERSION]: TRACKING_VERSION, 122 | [DIMENSIONS.CLIENT_ID]: tracker.get('clientId'), 123 | [DIMENSIONS.WINDOW_ID]: v4(), 124 | }); 125 | }); 126 | 127 | // Adds tracking to record each the type, time, uuid, and visibility state 128 | // of each hit immediately before it's sent. 129 | gaAll((tracker) => { 130 | const originalBuildHitTask = tracker.get('buildHitTask'); 131 | tracker.set('buildHitTask', (model) => { 132 | const qt = model.get('queueTime') || 0; 133 | model.set(DIMENSIONS.HIT_TIME, String(new Date() - qt), true); 134 | model.set(DIMENSIONS.HIT_ID, v4(), true); 135 | model.set(DIMENSIONS.HIT_TYPE, model.get('hitType'), true); 136 | model.set(DIMENSIONS.VISIBILITY_STATE, document.visibilityState, true); 137 | 138 | originalBuildHitTask(model); 139 | }); 140 | }); 141 | }; 142 | 143 | 144 | /** 145 | * Requires select autotrack plugins and initializes each one with its 146 | * respective configuration options. As an example of using multiple 147 | * trackers, this function only requires the `maxScrollTracker` and 148 | * `pageVisibilityTracker` plugins on the test trackers, so you can ensure the 149 | * data collected is relevant prior to sending it to your production property. 150 | */ 151 | const requireAutotrackPlugins = () => { 152 | gaAll('require', 'cleanUrlTracker', { 153 | stripQuery: true, 154 | queryDimensionIndex: getDefinitionIndex(DIMENSIONS.URL_QUERY_PARAMS), 155 | trailingSlash: 'remove', 156 | }); 157 | gaAll('require', 'maxScrollTracker', { 158 | sessionTimeout: 30, 159 | maxScrollMetricIndex: getDefinitionIndex(METRICS.MAX_SCROLL_PERCENTAGE), 160 | }); 161 | gaAll('require', 'outboundLinkTracker', { 162 | events: ['click', 'contextmenu'], 163 | }); 164 | gaAll('require', 'pageVisibilityTracker', { 165 | sendInitialPageview: true, 166 | pageLoadsMetricIndex: getDefinitionIndex(METRICS.PAGE_LOADS), 167 | visibleMetricIndex: getDefinitionIndex(METRICS.PAGE_VISIBLE), 168 | sessionTimeout: 30, 169 | fieldsObj: { [DIMENSIONS.HIT_SOURCE]: 'pageVisibilityTracker' }, 170 | }); 171 | gaAll('require', 'urlChangeTracker', { 172 | fieldsObj: { [DIMENSIONS.HIT_SOURCE]: 'urlChangeTracker' }, 173 | }); 174 | }; 175 | 176 | 177 | /** 178 | * Gets the DOM and window load times and sends them as custom metrics to 179 | * Google Analytics via an event hit. 180 | */ 181 | const sendNavigationTimingMetrics = () => { 182 | // Only track performance in supporting browsers. 183 | if (!(window.performance && window.performance.timing)) return; 184 | 185 | // If the window hasn't loaded, run this function after the `load` event. 186 | if (document.readyState !== 'complete') { 187 | window.addEventListener('load', sendNavigationTimingMetrics); 188 | return; 189 | } 190 | 191 | const nt = performance.timing; 192 | const navStart = nt.navigationStart; 193 | 194 | const responseEnd = Math.round(nt.responseEnd - navStart); 195 | const domLoaded = Math.round(nt.domContentLoadedEventStart - navStart); 196 | const windowLoaded = Math.round(nt.loadEventStart - navStart); 197 | 198 | // In some edge cases browsers return very obviously incorrect NT values, 199 | // e.g. 0, negative, or future times. This validates values before sending. 200 | const allValuesAreValid = (...values) => values.every(value => value > 0 && value < 6e6); 201 | 202 | if (allValuesAreValid(responseEnd, domLoaded, windowLoaded)) { 203 | gaAll('send', 'event', { 204 | eventCategory: 'Navigation Timing', 205 | eventAction: 'track', 206 | eventLabel: NULL_VALUE, 207 | nonInteraction: true, 208 | [METRICS.RESPONSE_END_TIME]: responseEnd, 209 | [METRICS.DOM_LOAD_TIME]: domLoaded, 210 | [METRICS.WINDOW_LOAD_TIME]: windowLoaded, 211 | }); 212 | } 213 | }; 214 | 215 | 216 | /** 217 | * Initializes all the analytics setup. Creates trackers and sets initial 218 | * values on the trackers. 219 | */ 220 | export const init = () => { 221 | // Initialize the command queue in case analytics.js hasn't loaded yet. 222 | // eslint-disable-next-line no-return-assign 223 | window.ga = window.ga || ((...args) => (ga.q = ga.q || []).push(args)); 224 | 225 | createTrackers(); 226 | trackErrors(); 227 | trackCustomDimensions(); 228 | requireAutotrackPlugins(); 229 | sendNavigationTimingMetrics(); 230 | }; 231 | -------------------------------------------------------------------------------- /app/analytics/index.js: -------------------------------------------------------------------------------- 1 | function activateAnalytics() { 2 | import(/* webpackChunkName: "analytics" */ './google-analytics') 3 | .then(analytics => analytics.init()); 4 | } 5 | 6 | export default activateAnalytics; 7 | -------------------------------------------------------------------------------- /app/assets/images/hnpwa-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwanno/hnpwa-react/264d72bdea97ccee76d8f370b8a89f099d246603/app/assets/images/hnpwa-logo.png -------------------------------------------------------------------------------- /app/client.jsx: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import 'whatwg-fetch'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { AppContainer } from 'react-hot-loader'; 6 | import { Provider } from 'react-redux'; 7 | import { ConnectedRouter } from 'react-router-redux'; 8 | import transit from 'transit-immutable-js'; 9 | 10 | import configureStore from 'store/configureStore'; 11 | import { history, middleware as routerMiddleware } from 'store/history'; 12 | import activateAnalytics from './analytics'; 13 | 14 | import './scss/style.scss'; 15 | 16 | const preloadedState = window.__PRELOADED_STATE__; 17 | delete window.__PRELOADED_STATE__; 18 | 19 | const store = configureStore({ 20 | routerMiddleware, 21 | preloadedState: preloadedState ? transit.fromJSON(preloadedState) : undefined, 22 | }); 23 | 24 | const MOUNT_NODE = document.getElementById('root'); 25 | 26 | const render = () => { 27 | // eslint-disable-next-line global-require 28 | const AppShell = require('components/AppShell').default; 29 | 30 | ReactDOM.hydrate( 31 | 32 | 33 | 34 | 35 | 36 | 37 | , 38 | MOUNT_NODE, 39 | ); 40 | }; 41 | 42 | if (__DEV__) { 43 | if (module.hot) { 44 | module.hot.accept('components/AppShell', () => { 45 | ReactDOM.unmountComponentAtNode(MOUNT_NODE); 46 | render(); 47 | }); 48 | } 49 | } 50 | 51 | render(); 52 | activateAnalytics(); 53 | -------------------------------------------------------------------------------- /app/components/AppShell.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect, Route, Switch } from 'react-router-dom'; 3 | 4 | import Header from 'components/Header'; 5 | import { Feed, Item, User } from 'pages'; 6 | 7 | function AppShell() { 8 | return ( 9 |
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | ); 25 | } 26 | 27 | export default AppShell; 28 | -------------------------------------------------------------------------------- /app/components/HackerNewsItem/HackerNewsItem.scss: -------------------------------------------------------------------------------- 1 | .HackerNewsItem__content { 2 | margin: 30px 0; 3 | } 4 | -------------------------------------------------------------------------------- /app/components/HackerNewsItem/HackerNewsItem.spec.jsx: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import { shallow } from 'enzyme'; 4 | 5 | import HackerNewsItem from './'; 6 | 7 | describe('', () => { 8 | it('should match snapshot when render default', () => { 9 | const wrapper = shallow(); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | 13 | it('should render and ', () => { 14 | const item = Immutable.Map({ 15 | commentsCount: 14, 16 | id: 1, 17 | timeAgo: '6 years ago', 18 | title: 'React with TypeScript', 19 | points: 14, 20 | user: 'taehwanno', 21 | url: 'https://github.com/taehwanno', 22 | }); 23 | const comments = Immutable.List([1, 2, 3, 4]); 24 | const wrapper = shallow(); 25 | expect(wrapper).toMatchSnapshot(); 26 | }); 27 | 28 | it('should calls props.onItemFetch in componentWillMount when the item does not exist', () => { 29 | const itemId = 1; 30 | const onItemFetch = jest.fn(() => Promise.resolve()); 31 | shallow(); 32 | expect(onItemFetch.mock.calls.length).toBe(1); 33 | expect(onItemFetch.mock.calls[0]).toEqual([itemId]); 34 | }); 35 | 36 | it('should not calls props.onItemFetch in componentWillMount when the item does exist', () => { 37 | const onItemFetch = jest.fn(); 38 | shallow(); 39 | expect(onItemFetch.mock.calls.length).toBe(0); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /app/components/HackerNewsItem/HackerNewsItem.stories.jsx: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | import { createStore } from 'redux'; 6 | import { storiesOf } from '@storybook/react'; 7 | import { action } from '@storybook/addon-actions'; 8 | 9 | import rootReducer from 'store/rootReducer'; 10 | import HackerNewsItem from './'; 11 | 12 | const stories = storiesOf('HackerNewsItem', module); 13 | 14 | const comments = Immutable.Map({ 15 | byId: Immutable.Map(new Map([ 16 | [3340746, Immutable.Map({ 17 | id: 3340746, 18 | level: 2, 19 | user: 'akg', 20 | time: 1323630681, 21 | time_ago: '6 years ago', 22 | timeAgo: '6 years ago', 23 | content: '

You\'re right. But it was after that pain-staking experience that I became fully engrossed in using unittests for all non-trivial functionality. Live and learn.', 24 | comments: Immutable.List(), 25 | parent: 3339842, 26 | })], 27 | [3339842, Immutable.Map({ 28 | id: 3339842, 29 | level: 1, 30 | user: 'Confusion', 31 | time: 1323601529, 32 | time_ago: '6 years ago', 33 | timeAgo: '6 years ago', 34 | content: '

If I have to venture a guess, I guess you didn\'t have a comprehensive set of tests at the function/method level of the code? Having that would probably have caught the bug, because you would have written a test for correctly executing the code in that branch.', 35 | comments: Immutable.List([3340746]), 36 | parent: 3338903, 37 | })], 38 | [3338903, Immutable.Map({ 39 | id: 3338903, 40 | level: 0, 41 | user: 'akg', 42 | time: 1323563056, 43 | time_ago: '6 years ago', 44 | timeAgo: '6 years ago', 45 | content: '

Reminds me of the time I had written a physical simulation engine back in grad school and there was a "minus" sign error. Of course, the error was rare enough that we didn\'t notice it until after the code was used in a real production environment. Tracking down one minus sign in several hundred thousands of lines is a pain. Not to mention the uneasy feeling you get after you solve it, "How was everything ever working correctly before!? What else did we overlook?"', 46 | comments: Immutable.List([3339842]), 47 | parent: 3338485, 48 | })], 49 | [3339723, Immutable.Map({ 50 | id: 3339723, 51 | level: 0, 52 | user: 'TwoBit', 53 | time: 1323596319, 54 | time_ago: '6 years ago', 55 | timeAgo: '6 years ago', 56 | content: '

They could have solved that bug with one developer in ten minutes by just telling the PS3 to generate a core dump and running addr2line.exe on the core dump report\'s callstacks.

And the report places the blame on the server instead of their code. Clearly it\'s their code\'s fault for doing blocking sockets calls in a main thread.', 57 | comments: Immutable.List(), 58 | parent: 3338485, 59 | })], 60 | [3338485, Immutable.Map({ 61 | id: 3338485, 62 | title: 'Lamest bug we ever encountered', 63 | points: 76, 64 | user: 'exch', 65 | time: 1323550709, 66 | time_ago: '6 years ago', 67 | timeAgo: '6 years ago', 68 | type: 'link', 69 | url: 'http://joostdevblog.blogspot.com/2011/12/lamest-bug-we-ever-encountered.html', 70 | domain: 'joostdevblog.blogspot.com', 71 | comments_count: 23, 72 | commentsCount: 23, 73 | comments: Immutable.List([3338903, 3339723]), 74 | })], 75 | ])), 76 | posts: Immutable.Map(), 77 | }); 78 | 79 | const preloadedState = Immutable.Map({ comments }); 80 | 81 | const store = createStore(rootReducer, preloadedState); 82 | 83 | stories 84 | .add('default', () => ( 85 | 86 | 87 | 101 | 102 | 103 | )); 104 | -------------------------------------------------------------------------------- /app/components/HackerNewsItem/__snapshots__/HackerNewsItem.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match snapshot when render default 1`] = ` 4 | 14 | `; 15 | 16 | exports[` should render and 1`] = ` 17 |

20 |
23 | 32 |
33 | 37 | 41 | 45 | 49 |
50 | `; 51 | -------------------------------------------------------------------------------- /app/components/HackerNewsItem/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import HackerNewsComment from 'containers/HackerNewsComment'; 5 | import HackerNewsListItem from 'components/HackerNewsListItem'; 6 | import LoadingIndicator from 'components/LoadingIndicator'; 7 | 8 | import './HackerNewsItem.scss'; 9 | 10 | const propTypes = { 11 | comments: PropTypes.object, // eslint-disable-line react/forbid-prop-types 12 | done: PropTypes.func, 13 | item: PropTypes.object, // eslint-disable-line react/forbid-prop-types 14 | itemId: PropTypes.number, 15 | onItemFetch: PropTypes.func, 16 | }; 17 | 18 | const defaultProps = { 19 | comments: null, 20 | done() {}, 21 | item: null, 22 | itemId: 0, 23 | onItemFetch() { return Promise.resolve(); }, 24 | }; 25 | 26 | class HackerNewsItem extends React.Component { 27 | componentWillMount() { 28 | const { done, item, itemId } = this.props; 29 | 30 | if (!item) { 31 | this.props.onItemFetch(itemId).then(done, done); 32 | } 33 | } 34 | 35 | render() { 36 | const { comments, item } = this.props; 37 | if (!comments || !item) { 38 | return ( 39 | 43 | ); 44 | } 45 | 46 | return ( 47 |
48 |
49 | 50 |
51 | {comments.map(id => ).toArray()} 52 |
53 | ); 54 | } 55 | } 56 | 57 | HackerNewsItem.propTypes = propTypes; 58 | HackerNewsItem.defaultProps = defaultProps; 59 | 60 | export default HackerNewsItem; 61 | -------------------------------------------------------------------------------- /app/components/HackerNewsList/HackerNewsList.scss: -------------------------------------------------------------------------------- 1 | .HackerNewsList { 2 | padding-left: 20px; 3 | 4 | > li { 5 | list-style: none; 6 | } 7 | } 8 | 9 | .HackerNewsList__noti { 10 | text-align: center; 11 | } 12 | 13 | .HackerNewsList__item { 14 | margin: 30px 0; 15 | } 16 | 17 | .HackerNewsList__index { 18 | font-size: 2em; 19 | position: absolute; 20 | text-align: center; 21 | width: 1em; 22 | } 23 | -------------------------------------------------------------------------------- /app/components/HackerNewsList/HackerNewsList.spec.jsx: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import { shallow } from 'enzyme'; 4 | 5 | import HackerNewsList from './'; 6 | 7 | describe('', () => { 8 | it('should match snapshot when render default', () => { 9 | const wrapper = shallow(); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | 13 | it('should match snapshot when render feeds', () => { 14 | const feeds = Immutable.fromJS([ 15 | { 16 | id: 15350263, 17 | title: 'Keybase\'s mission is to make encryption mainstream', 18 | points: 172, 19 | user: 'BradyDale', 20 | time: 1506534023, 21 | timeAgo: '20 hours ago', 22 | commentsCount: 79, 23 | type: 'link', 24 | url: 'http://observer.com/2017/09/keybase-max-krohn-chris-coyne-okcupid/', 25 | domain: 'observer.com', 26 | }, 27 | { 28 | id: 15351433, 29 | title: 'What happens after a defendant is found not guilty by reason of insanity?', 30 | points: 74, 31 | user: 'mcone', 32 | time: 1506540664, 33 | timeAgo: '18 hours ago', 34 | commentsCount: 131, 35 | type: 'link', 36 | url: 'https://www.nytimes.com/2017/09/27/magazine/when-not-guilty-is-a-life-sentence.html', 37 | domain: 'nytimes.com', 38 | }, 39 | ]); 40 | const wrapper = shallow(); 41 | expect(wrapper).toMatchSnapshot(); 42 | }); 43 | 44 | it('should have .HackerNewsList__noti when have no items', () => { 45 | const wrapper = shallow(); 46 | expect(wrapper.find('.HackerNewsList__noti')).toHaveLength(1); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /app/components/HackerNewsList/HackerNewsList.stories.jsx: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import { storiesOf } from '@storybook/react'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | 6 | import HackerNewsList from './'; 7 | 8 | const stories = storiesOf('HackerNewsList', module); 9 | 10 | stories 11 | .addDecorator(story => ( 12 | 13 | {story()} 14 | 15 | )) 16 | .add('fetching data', () => ) 17 | .add('no data', () => ) 18 | .add('with data', () => ( 19 | 167 | )); 168 | -------------------------------------------------------------------------------- /app/components/HackerNewsList/__snapshots__/HackerNewsList.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match snapshot when render default 1`] = ` 4 |
    7 | 17 |

    20 | There are no items to show. 21 |

    22 |
23 | `; 24 | 25 | exports[` should match snapshot when render feeds 1`] = ` 26 |
    29 | 39 |
  • 43 | 46 | 1 47 | 48 | 60 |
  • 61 |
  • 65 | 68 | 2 69 | 70 | 82 |
  • 83 |
84 | `; 85 | -------------------------------------------------------------------------------- /app/components/HackerNewsList/index.jsx: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import HackerNewsListItem from 'components/HackerNewsListItem'; 6 | import LoadingIndicator from 'components/LoadingIndicator'; 7 | 8 | import './HackerNewsList.scss'; 9 | 10 | const propTypes = { 11 | feeds: PropTypes.object, // eslint-disable-line react/forbid-prop-types 12 | isFetching: PropTypes.bool, 13 | }; 14 | 15 | const defaultProps = { 16 | feeds: Immutable.List(), 17 | isFetching: false, 18 | }; 19 | 20 | function HackerNewsList({ feeds, isFetching }) { 21 | const haveNoItems = !isFetching && feeds.size === 0; 22 | 23 | return ( 24 |
    25 | 29 | {haveNoItems &&

    There are no items to show.

    } 30 | {!isFetching && feeds.map((feed, index) => ( 31 |
  • 32 | {index + 1} 33 | 34 |
  • 35 | )).toArray()} 36 |
37 | ); 38 | } 39 | 40 | HackerNewsList.propTypes = propTypes; 41 | HackerNewsList.defaultProps = defaultProps; 42 | 43 | export default HackerNewsList; 44 | -------------------------------------------------------------------------------- /app/components/HackerNewsListItem/HackerNewsListItem.scss: -------------------------------------------------------------------------------- 1 | .HackerNewsListItem { 2 | padding-left: 4em; 3 | } 4 | 5 | .HackerNewsListItem__title { 6 | color: $color-black; 7 | text-decoration: none; 8 | } 9 | 10 | .HackerNewsListItem__info { 11 | font-size: 0.8em; 12 | margin-top: 0.3em; 13 | } 14 | 15 | .HackerNewsListItem__link { 16 | color: $color-black; 17 | } 18 | -------------------------------------------------------------------------------- /app/components/HackerNewsListItem/HackerNewsListItem.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import HackerNewsListItem from './'; 5 | 6 | describe('', () => { 7 | it('should match snapshot when render default', () => { 8 | const wrapper = shallow(); 9 | expect(wrapper).toMatchSnapshot(); 10 | }); 11 | 12 | it('should not render points when props.points === null', () => { 13 | const wrapper = shallow(( 14 | 23 | )); 24 | expect(wrapper).toMatchSnapshot(); 25 | }); 26 | 27 | it('should not render user when props.user === null', () => { 28 | const wrapper = shallow(( 29 | 38 | )); 39 | expect(wrapper).toMatchSnapshot(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /app/components/HackerNewsListItem/HackerNewsListItem.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import HackerNewsListItem from './'; 6 | 7 | const stories = storiesOf('HackerNewsListItem', module); 8 | 9 | stories 10 | .addDecorator(story => ( 11 | 12 | {story()} 13 | 14 | )) 15 | .add('default', () => ( 16 | 25 | )) 26 | .add('user is null', () => ( 27 | 36 | )) 37 | .add('points are null', () => ( 38 | 47 | )) 48 | .add('user and points are null', () => ( 49 | 58 | )); 59 | -------------------------------------------------------------------------------- /app/components/HackerNewsListItem/__snapshots__/HackerNewsListItem.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match snapshot when render default 1`] = ` 4 | 30 | `; 31 | 32 | exports[` should not render points when props.points === null 1`] = ` 33 |
36 | 41 | Keybase's mission is to make encryption mainstream 42 | 43 |
46 | by 47 | 53 | BradyDale 54 | 55 | 56 | 20 hours ago 57 | | 58 | 59 | 65 | 48 66 | comments 67 | 68 |
69 |
70 | `; 71 | 72 | exports[` should not render user when props.user === null 1`] = ` 73 |
76 | 81 | Keybase's mission is to make encryption mainstream 82 | 83 |
86 | 147 points 87 | 88 | 20 hours ago 89 | | 90 | 91 | 97 | 48 98 | comments 99 | 100 |
101 |
102 | `; 103 | -------------------------------------------------------------------------------- /app/components/HackerNewsListItem/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import './HackerNewsListItem.scss'; 6 | 7 | const propTypes = { 8 | commentsCount: PropTypes.number, 9 | id: PropTypes.number, 10 | timeAgo: PropTypes.string, 11 | title: PropTypes.string, 12 | points: PropTypes.number, 13 | user: PropTypes.string, 14 | url: PropTypes.string, 15 | }; 16 | 17 | const defaultProps = { 18 | commentsCount: 0, 19 | id: 0, 20 | timeAgo: '', 21 | title: '', 22 | points: 0, 23 | user: '', 24 | url: '', 25 | }; 26 | 27 | function HackerNewsListItem({ 28 | commentsCount, 29 | id, 30 | points, 31 | timeAgo, 32 | title, 33 | url, 34 | user, 35 | }) { 36 | return ( 37 |
38 | {title} 39 |
40 | {points !== null && `${points} points`} 41 | {user && ' by '} 42 | {user && ( 43 | 48 | {user} 49 | 50 | )} {timeAgo} | {' '} 51 | 56 | {commentsCount} comments 57 | 58 |
59 |
60 | ); 61 | } 62 | 63 | HackerNewsListItem.propTypes = propTypes; 64 | HackerNewsListItem.defaultProps = defaultProps; 65 | 66 | export default HackerNewsListItem; 67 | -------------------------------------------------------------------------------- /app/components/HackerNewsUser/HackerNewsUser.scss: -------------------------------------------------------------------------------- 1 | .HackerNewsUser { 2 | padding: 2em; 3 | } 4 | 5 | .HackerNewsUser__links { 6 | margin-top: 1em; 7 | 8 | a { 9 | color: $color-black; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/components/HackerNewsUser/HackerNewsUser.spec.jsx: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import { shallow } from 'enzyme'; 4 | 5 | import HackerNewsUser from './'; 6 | 7 | describe('', () => { 8 | it('should match snapshot when render default', () => { 9 | const wrapper = shallow(); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | 13 | it('should render user view when information exist', () => { 14 | const information = Immutable.Map({ created: '3 years ago', karma: 1234 }); 15 | const wrapper = shallow(); 16 | expect(wrapper).toMatchSnapshot(); 17 | }); 18 | 19 | it('should calls props.onUserFetch in componentWillMount when props.information not exist', () => { 20 | const onUserFetch = jest.fn(() => Promise.resolve()); 21 | shallow(); 22 | expect(onUserFetch.mock.calls.length).toBe(1); 23 | expect(onUserFetch.mock.calls[0]).toEqual(['taehwanno']); 24 | }); 25 | 26 | it('should not calls props.onUserFetch in componentWillMount when props.information exist', () => { 27 | const onUserFetch = jest.fn(); 28 | shallow(); 29 | expect(onUserFetch.mock.calls.length).toBe(0); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /app/components/HackerNewsUser/__snapshots__/HackerNewsUser.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match snapshot when render default 1`] = ` 4 | 14 | `; 15 | 16 | exports[` should render user view when information exist 1`] = ` 17 |
20 | 21 | 22 | 23 | 26 | 29 | 30 | 31 | 34 | 37 | 38 | 39 | 42 | 45 | 46 | 47 |
24 | User: 25 | 27 | taehwanno 28 |
32 | Created: 33 | 35 | 3 years ago 36 |
40 | Karma: 41 | 43 | 1234 44 |
48 | 69 |
70 | `; 71 | -------------------------------------------------------------------------------- /app/components/HackerNewsUser/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import LoadingIndicator from 'components/LoadingIndicator'; 5 | 6 | import './HackerNewsUser.scss'; 7 | 8 | const propTypes = { 9 | done: PropTypes.func, 10 | information: PropTypes.shape({ created: PropTypes.string, karma: PropTypes.number }), 11 | user: PropTypes.string.isRequired, 12 | onUserFetch: PropTypes.func, 13 | }; 14 | 15 | const defaultProps = { 16 | done() {}, 17 | information: null, 18 | onUserFetch() { return Promise.resolve(); }, 19 | }; 20 | 21 | class HackerNewsUser extends React.Component { 22 | componentWillMount() { 23 | const { 24 | done, 25 | information, 26 | user, 27 | onUserFetch, 28 | } = this.props; 29 | 30 | if (!information) { 31 | onUserFetch(user).then(done, done); 32 | } 33 | } 34 | 35 | render() { 36 | const { information, user } = this.props; 37 | 38 | if (!information) { 39 | return ( 40 | 44 | ); 45 | } 46 | 47 | return ( 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
User: {user}
Created: {information.get('created')}
Karma: {information.get('karma')}
65 |
66 | submissions 67 | {' | '} 68 | comments 69 | {' | '} 70 | favorites 71 |
72 |
73 | ); 74 | } 75 | } 76 | 77 | HackerNewsUser.propTypes = propTypes; 78 | HackerNewsUser.defaultProps = defaultProps; 79 | 80 | export default HackerNewsUser; 81 | -------------------------------------------------------------------------------- /app/components/Header/Header.scss: -------------------------------------------------------------------------------- 1 | .Header { 2 | background-color: $color-main; 3 | box-sizing: border-box; 4 | height: $header-height; 5 | padding: 16px 100px; 6 | width: 100%; 7 | } 8 | 9 | .Header__navlink { 10 | color: $color-white; 11 | margin: 0 10px; 12 | text-decoration: none; 13 | } 14 | 15 | .Header__navlink--active { 16 | text-decoration: underline; 17 | text-underline-position: under; 18 | } 19 | 20 | .Header__logo { 21 | height: 20px; 22 | left: 20px; 23 | position: absolute; 24 | top: 15px; 25 | } 26 | 27 | .Header__title { 28 | color: $color-white; 29 | float: right; 30 | } 31 | 32 | @media (max-width: 600px) { 33 | .Header__title { 34 | display: none; 35 | } 36 | } 37 | 38 | @media (max-width: 450px) { 39 | .Header { 40 | padding: 18px 10px; 41 | } 42 | 43 | .Header__logo { 44 | display: none; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/components/Header/Header.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Header, { makeIsActive } from './'; 5 | 6 | describe('
', () => { 7 | it('should match snapshot when render default', () => { 8 | const wrapper = shallow(
); 9 | expect(wrapper).toMatchSnapshot(); 10 | }); 11 | 12 | it('should make a function that returns true when location.pathname include path', () => { 13 | const newsIsActive = makeIsActive('/news'); 14 | expect(newsIsActive(null, { pathname: '/news/1' })).toBe(true); 15 | expect(newsIsActive(null, { pathname: '/newest/1' })).toBe(false); 16 | 17 | const newestIsActive = makeIsActive('/newest'); 18 | expect(newestIsActive(null, { pathname: '/newest/1' })).toBe(true); 19 | expect(newestIsActive(null, { pathname: '/show/1' })).toBe(false); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /app/components/Header/Header.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MemoryRouter } from 'react-router-dom'; 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import Header from './'; 6 | 7 | const stories = storiesOf('Header', module); 8 | 9 | stories 10 | .addDecorator(story => ( 11 | {story()} 12 | )) 13 | .add('default', () => ( 14 |
15 |
16 |
17 | )); 18 | -------------------------------------------------------------------------------- /app/components/Header/__snapshots__/Header.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`
should match snapshot when render default 1`] = ` 4 | 75 | `; 76 | -------------------------------------------------------------------------------- /app/components/Header/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, NavLink } from 'react-router-dom'; 3 | 4 | import hnpwaLogo from 'assets/images/hnpwa-logo.png'; 5 | import './Header.scss'; 6 | 7 | export function makeIsActive(path) { 8 | return function isActive(match, location) { 9 | return location.pathname.indexOf(path) !== -1; 10 | }; 11 | } 12 | 13 | function Header() { 14 | return ( 15 | 63 | ); 64 | } 65 | 66 | export default Header; 67 | -------------------------------------------------------------------------------- /app/components/LoadingIndicator/LoadingIndicator.scss: -------------------------------------------------------------------------------- 1 | $_animation-delay: 200ms; 2 | $_translate-y: 10px; 3 | 4 | .LoadingIndicator__col { 5 | animation-duration: 1s; 6 | animation-iteration-count: infinite; 7 | animation-name: up-and-down; 8 | background-color: transparentize($color-main, 0.1); 9 | float: left; 10 | height: 30px; 11 | width: 10px; 12 | } 13 | 14 | .LoadingIndicator__col:nth-of-type(1) { 15 | animation-delay: $_animation-delay; 16 | } 17 | 18 | .LoadingIndicator__col:nth-of-type(2) { 19 | animation-delay: $_animation-delay * 2; 20 | margin: 0 7px; 21 | } 22 | 23 | .LoadingIndicator__col:nth-of-type(3) { 24 | animation-delay: $_animation-delay * 3; 25 | } 26 | 27 | @keyframes up-and-down { 28 | 0% { 29 | transform: translateY(0); 30 | } 31 | 32 | 25% { 33 | transform: translateY($_translate-y); 34 | } 35 | 36 | 75% { 37 | transform: translateY(-$_translate-y); 38 | } 39 | 40 | 100% { 41 | transform: translateY(0); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/components/LoadingIndicator/LoadingIndicator.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import LoadingIndicator from './'; 5 | 6 | describe('', () => { 7 | it('should return null when render default', () => { 8 | const wrapper = shallow(); 9 | expect(wrapper.type()).toBe(null); 10 | }); 11 | 12 | it('should return DOM tree when props.active === true', () => { 13 | const wrapper = shallow(); 14 | expect(wrapper).toMatchSnapshot(); 15 | }); 16 | 17 | it('should pass style to root div when props.style exist', () => { 18 | const style = { position: 'absolute', top: 10, left: 10 }; 19 | const wrapper = shallow(); 20 | expect(wrapper.props().style).toEqual(style); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /app/components/LoadingIndicator/LoadingIndicator.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import LoadingIndicator from './'; 5 | 6 | const stories = storiesOf('LoadingIndicator', module); 7 | 8 | stories 9 | .add('default', () => ( 10 | 11 | )); 12 | -------------------------------------------------------------------------------- /app/components/LoadingIndicator/__snapshots__/LoadingIndicator.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should return DOM tree when props.active === true 1`] = ` 4 |
8 |
11 |
14 |
17 |
18 | `; 19 | -------------------------------------------------------------------------------- /app/components/LoadingIndicator/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './LoadingIndicator.scss'; 5 | 6 | const propTypes = { 7 | active: PropTypes.bool, 8 | style: PropTypes.object, // eslint-disable-line react/forbid-prop-types 9 | }; 10 | 11 | const defaultProps = { 12 | active: false, 13 | style: {}, 14 | }; 15 | 16 | function LoadingIndicator({ active, style }) { 17 | if (!active) return null; 18 | 19 | return ( 20 |
21 |
22 |
23 |
24 |
25 | ); 26 | } 27 | 28 | LoadingIndicator.propTypes = propTypes; 29 | LoadingIndicator.defaultProps = defaultProps; 30 | 31 | export default LoadingIndicator; 32 | -------------------------------------------------------------------------------- /app/components/LocationPagination/LocationPagination.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Pagination from 'components/Pagination'; 5 | import LocationPagination from './'; 6 | 7 | const history = { push() {} }; 8 | const location = { pathname: '/news/1' }; 9 | 10 | describe('', () => { 11 | it('should match snapshot when render default', () => { 12 | const wrapper = shallow(); 13 | expect(wrapper).toMatchSnapshot(); 14 | }); 15 | 16 | it('should return query array when calls LocationPagination.getRequestQuery', () => { 17 | expect(LocationPagination.getRequestQuery('/news/1')).toEqual(['news', '1']); 18 | }); 19 | 20 | it('should calls props.onPaginate in componentWillMount when URL path is valid and props.feedCount === 0', () => { 21 | const onPaginate = jest.fn(() => Promise.resolve()); 22 | shallow(); 23 | expect(onPaginate.mock.calls.length).toBe(1); 24 | }); 25 | 26 | it('should not calls props.onPaginate in componentWillMount when URL path is valid and props.feedCount !== 0', () => { 27 | const onPaginate = jest.fn(() => Promise.resolve()); 28 | shallow(( 29 | 35 | )); 36 | expect(onPaginate.mock.calls.length).toBe(0); 37 | }); 38 | 39 | it('should not calls props.onPaginate when location.pathname === \'/\' in componentWillMount', () => { 40 | const onPaginate = jest.fn(); 41 | shallow(( 42 | 47 | )); 48 | expect(onPaginate.mock.calls.length).toBe(0); 49 | }); 50 | 51 | it('should calls props.onPaginate when props.location.pathname is changed in componentWillReceiveProps', () => { 52 | const onPaginate = jest.fn(() => Promise.resolve()); 53 | const wrapper = shallow(( 54 | 59 | )); 60 | expect(onPaginate.mock.calls.length).toBe(1); 61 | wrapper.setProps({ location: { pathname: '/news/1' } }); 62 | expect(onPaginate.mock.calls.length).toBe(1); 63 | wrapper.setProps({ location: { pathname: '/jobs/1' } }); 64 | expect(onPaginate.mock.calls.length).toBe(2); 65 | }); 66 | 67 | it('should calls props.history.push when simulate onPaginate', () => { 68 | const page = 2; 69 | const push = jest.fn(); 70 | const historyMock = { push }; 71 | const wrapper = shallow(); 72 | wrapper.find(Pagination).simulate('paginate', page); 73 | expect(push.mock.calls.length).toBe(1); 74 | expect(push.mock.calls[0]).toEqual([`/news/${page}`]); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /app/components/LocationPagination/__snapshots__/LocationPagination.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match snapshot when render default 1`] = ` 4 | 8 | `; 9 | -------------------------------------------------------------------------------- /app/components/LocationPagination/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Pagination from 'components/Pagination'; 5 | 6 | const propTypes = { 7 | currentPage: PropTypes.number, 8 | done: PropTypes.func, 9 | feedCount: PropTypes.number, 10 | history: PropTypes.shape({ push: PropTypes.func }).isRequired, 11 | location: PropTypes.shape({ pathname: PropTypes.string }).isRequired, 12 | onPaginate: PropTypes.func, 13 | }; 14 | 15 | const defaultProps = { 16 | currentPage: 1, 17 | done() {}, 18 | feedCount: 0, 19 | onPaginate() { return Promise.resolve(); }, 20 | }; 21 | 22 | class LocationPagination extends React.Component { 23 | static getRequestQuery(pathname) { 24 | return pathname.split('/').filter(v => !!v); 25 | } 26 | 27 | constructor(props) { 28 | super(props); 29 | this.handlePaginate = this.handlePaginate.bind(this); 30 | } 31 | 32 | componentWillMount() { 33 | const { done, feedCount } = this.props; 34 | const [type, stringPage] = LocationPagination.getRequestQuery(this.props.location.pathname); 35 | const page = parseInt(stringPage, 10); 36 | 37 | if (type && !Number.isNaN(page) && feedCount === 0) { 38 | this.props.onPaginate(type, parseInt(page, 10)).then(done, done); 39 | } 40 | } 41 | 42 | componentWillReceiveProps(nextProps) { 43 | if (nextProps.location.pathname !== this.props.location.pathname) { 44 | const [type, page] = LocationPagination.getRequestQuery(nextProps.location.pathname); 45 | this.props.onPaginate(type, parseInt(page, 10)); 46 | } 47 | } 48 | 49 | handlePaginate(page) { 50 | const [type] = LocationPagination.getRequestQuery(this.props.location.pathname); 51 | this.props.history.push(`/${type}/${page}`); 52 | } 53 | 54 | render() { 55 | const { currentPage } = this.props; 56 | 57 | return ( 58 | 62 | ); 63 | } 64 | } 65 | 66 | LocationPagination.propTypes = propTypes; 67 | LocationPagination.defaultProps = defaultProps; 68 | 69 | export default LocationPagination; 70 | -------------------------------------------------------------------------------- /app/components/Pagination/Pagination.scss: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | box-shadow: 0 1px 2px $color-shadow; 3 | height: $header-height; 4 | text-align: center; 5 | width: 100%; 6 | } 7 | 8 | .Pagination__inner { 9 | display: inline-block; 10 | padding: 18px 0; 11 | } 12 | 13 | .Pagination__button { 14 | background-color: transparent; 15 | border: 0; 16 | cursor: pointer; 17 | } 18 | 19 | .Pagination__button--disabled { 20 | color: $color-gray; 21 | cursor: not-allowed; 22 | } 23 | 24 | .Pagination__number { 25 | display: inline-block; 26 | margin: 0 20px; 27 | } 28 | -------------------------------------------------------------------------------- /app/components/Pagination/Pagination.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Pagination from './'; 5 | 6 | describe('', () => { 7 | it('should match snapshot when render default', () => { 8 | const wrapper = shallow(); 9 | expect(wrapper).toMatchSnapshot(); 10 | }); 11 | 12 | it('should have .Pagination__button--disabled when props.currentPage === 1', () => { 13 | const wrapper = shallow(); 14 | const button = wrapper.find('.Pagination__button--disabled'); 15 | expect(button.props()['data-button']).toBe('prev'); 16 | }); 17 | 18 | it('should have .Pagination__button--disabled when props.currentPage === 10', () => { 19 | const wrapper = shallow(); 20 | const button = wrapper.find('.Pagination__button--disabled'); 21 | expect(button.props()['data-button']).toBe('next'); 22 | }); 23 | 24 | it('should calls onPaginate with currentPage - 1 when simulate prev button click event', () => { 25 | const currentPage = 3; 26 | const onPaginate = jest.fn(); 27 | const wrapper = shallow(( 28 | 33 | )); 34 | wrapper.find('.Pagination__button').at(0).simulate('click'); 35 | expect(onPaginate).toHaveBeenCalledTimes(1); 36 | expect(onPaginate).toHaveBeenCalledWith(currentPage - 1); 37 | }); 38 | 39 | it('should calls onPaginate with currentPage + 1 when simulate next button click event', () => { 40 | const currentPage = 3; 41 | const onPaginate = jest.fn(); 42 | const wrapper = shallow(( 43 | 48 | )); 49 | wrapper.find('.Pagination__button').at(1).simulate('click'); 50 | expect(onPaginate).toHaveBeenCalledTimes(1); 51 | expect(onPaginate).toHaveBeenCalledWith(currentPage + 1); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /app/components/Pagination/Pagination.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import Pagination from './'; 5 | 6 | const stories = storiesOf('Pagination', module); 7 | 8 | class PaginationWrapper extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { currentPage: 1 }; 12 | this.handlePaginate = this.handlePaginate.bind(this); 13 | } 14 | 15 | handlePaginate(number) { 16 | this.setState({ currentPage: number }); 17 | } 18 | 19 | render() { 20 | return ( 21 | 25 | ); 26 | } 27 | } 28 | 29 | stories 30 | .add('Pagination', () => ); 31 | -------------------------------------------------------------------------------- /app/components/Pagination/__snapshots__/Pagination.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match snapshot when render default 1`] = ` 4 |
7 |
10 | 20 | 1 21 | / 10 22 | 23 | 33 |
34 |
35 | `; 36 | -------------------------------------------------------------------------------- /app/components/Pagination/index.jsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import './Pagination.scss'; 6 | 7 | const propTypes = { 8 | currentPage: PropTypes.number, 9 | onPaginate: PropTypes.func, 10 | }; 11 | 12 | const defaultProps = { 13 | currentPage: 1, 14 | onPaginate() {}, 15 | }; 16 | 17 | function Pagination({ currentPage, onPaginate }) { 18 | const prevDisabled = currentPage === 1; 19 | const nextDisabled = currentPage === 10; 20 | 21 | const prevButtonClassName = cx('Pagination__button', { 22 | 'Pagination__button--disabled': prevDisabled, 23 | }); 24 | 25 | const nextButtonClassName = cx('Pagination__button', { 26 | 'Pagination__button--disabled': nextDisabled, 27 | }); 28 | 29 | return ( 30 |
31 |
32 | 41 | {currentPage} / 10 42 | {' '} 43 | 52 |
53 |
54 | ); 55 | } 56 | 57 | Pagination.propTypes = propTypes; 58 | Pagination.defaultProps = defaultProps; 59 | 60 | export default Pagination; 61 | -------------------------------------------------------------------------------- /app/containers/HackerNewsComment/HackerNewsComment.scss: -------------------------------------------------------------------------------- 1 | .HackerNewsComment { 2 | font-size: 13px; 3 | padding: 10px 2px 5px 20px; 4 | } 5 | 6 | .HackerNewsComment__head { 7 | color: $color-gray; 8 | 9 | button { 10 | background-color: transparent; 11 | border: 0; 12 | color: inherit; 13 | } 14 | 15 | a { 16 | color: inherit; 17 | } 18 | } 19 | 20 | .HackerNewsComment__inner--hide { 21 | display: none; 22 | } 23 | 24 | .HackerNewsComment__content { 25 | box-shadow: 0 1px 0 $color-shadow; 26 | line-height: 1.5; 27 | padding: 1px; 28 | } 29 | -------------------------------------------------------------------------------- /app/containers/HackerNewsComment/HackerNewsComment.spec.jsx: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import { shallow } from 'enzyme'; 4 | 5 | import { HackerNewsCommentInner } from './'; 6 | 7 | describe('', () => { 8 | it('should match snapshot when render default', () => { 9 | const wrapper = shallow(); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | 13 | it('should render comments and contents', () => { 14 | const contents = Immutable.Map({ 15 | user: 'taehwanno', 16 | timeAgo: '1 years ago', 17 | content: '

How you think React with TypeScript?

', 18 | }); 19 | const comments = Immutable.List([1, 2, 3, 4]); 20 | const wrapper = shallow(); 21 | expect(wrapper).toMatchSnapshot(); 22 | }); 23 | 24 | it('should have .HackerNewsComment__inner--hide when state.collapse === true', () => { 25 | const wrapper = shallow(); 26 | wrapper.setState({ collapse: true }); 27 | expect(wrapper.find('.HackerNewsComment__inner--hide').length).toBe(1); 28 | }); 29 | 30 | it('should switch button shape by state.collapse', () => { 31 | const wrapper = shallow(); 32 | expect(wrapper.state().collapse).toBe(false); 33 | expect(wrapper.find('button').children()).toMatchSnapshot(); 34 | wrapper.setState({ collapse: true }); 35 | expect(wrapper.find('button').children()).toMatchSnapshot(); 36 | }); 37 | 38 | it('should toggle state.collapse when simulate button click event', () => { 39 | const wrapper = shallow(); 40 | expect(wrapper.state().collapse).toBe(false); 41 | wrapper.find('button').simulate('click'); 42 | expect(wrapper.state().collapse).toBe(true); 43 | wrapper.find('button').simulate('click'); 44 | expect(wrapper.state().collapse).toBe(false); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /app/containers/HackerNewsComment/HackerNewsComment.stories.jsx: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | import { createStore } from 'redux'; 6 | import { storiesOf } from '@storybook/react'; 7 | 8 | import rootReducer from 'store/rootReducer'; 9 | import HackerNewsComment from './'; 10 | 11 | const stories = storiesOf('HackerNewsComment', module); 12 | 13 | const comments = Immutable.Map({ 14 | byId: Immutable.Map(new Map([ 15 | [3340746, Immutable.Map({ 16 | id: 3340746, 17 | level: 2, 18 | user: 'akg', 19 | time: 1323630681, 20 | time_ago: '6 years ago', 21 | timeAgo: '6 years ago', 22 | content: '

You\'re right. But it was after that pain-staking experience that I became fully engrossed in using unittests for all non-trivial functionality. Live and learn.', 23 | comments: Immutable.List(), 24 | parent: 3339842, 25 | })], 26 | [3339842, Immutable.Map({ 27 | id: 3339842, 28 | level: 1, 29 | user: 'Confusion', 30 | time: 1323601529, 31 | time_ago: '6 years ago', 32 | timeAgo: '6 years ago', 33 | content: '

If I have to venture a guess, I guess you didn\'t have a comprehensive set of tests at the function/method level of the code? Having that would probably have caught the bug, because you would have written a test for correctly executing the code in that branch.', 34 | comments: Immutable.List([3340746]), 35 | parent: 3338903, 36 | })], 37 | ])), 38 | posts: Immutable.Map(), 39 | }); 40 | 41 | const preloadedState = Immutable.Map({ comments }); 42 | 43 | const store = createStore(rootReducer, preloadedState); 44 | 45 | stories 46 | .add('default', () => ( 47 | 48 | 49 | 53 | 54 | 55 | )); 56 | -------------------------------------------------------------------------------- /app/containers/HackerNewsComment/__snapshots__/HackerNewsComment.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match snapshot when render default 1`] = ` 4 |

7 |
10 | 16 | 17 | 22 | 23 | 24 |
25 |
28 |
36 |
37 |
38 | `; 39 | 40 | exports[` should render comments and contents 1`] = ` 41 |
44 |
47 | 53 | 54 | 59 | taehwanno 60 | 61 | 62 | 63 | 1 years ago 64 | 65 |
66 |
69 |
How you think React with TypeScript?

", 74 | } 75 | } 76 | /> 77 | 81 | 85 | 89 | 93 |
94 |
95 | `; 96 | 97 | exports[` should switch button shape by state.collapse 1`] = `"[-]"`; 98 | 99 | exports[` should switch button shape by state.collapse 2`] = `"[+]"`; 100 | -------------------------------------------------------------------------------- /app/containers/HackerNewsComment/index.jsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import Immutable from 'immutable'; 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import { connect } from 'react-redux'; 6 | import { Link } from 'react-router-dom'; 7 | 8 | import { makeGetChildrenComments, makeGetCommentContents } from 'store/selectors'; 9 | 10 | import './HackerNewsComment.scss'; 11 | 12 | const propTypes = { 13 | comments: PropTypes.object, // eslint-disable-line react/forbid-prop-types 14 | contents: PropTypes.object, // eslint-disable-line react/forbid-prop-types 15 | }; 16 | 17 | const defaultProps = { 18 | comments: Immutable.List(), 19 | contents: Immutable.Map(), 20 | }; 21 | 22 | export class HackerNewsCommentInner extends React.Component { 23 | constructor(props) { 24 | super(props); 25 | this.state = { collapse: false }; 26 | this.toggleCollapse = this.toggleCollapse.bind(this); 27 | } 28 | 29 | toggleCollapse() { 30 | this.setState(prevState => ({ collapse: !prevState.collapse })); 31 | } 32 | 33 | render() { 34 | const { comments, contents } = this.props; 35 | const { collapse } = this.state; 36 | 37 | const innerClassName = cx('HackerNewsComment__inner', { 38 | 'HackerNewsComment__inner--hide': collapse, 39 | }); 40 | 41 | return ( 42 |
43 |
44 | 50 | {' '} 51 | 55 | {contents.get('user')} 56 | 57 | {' '} 58 | {contents.get('timeAgo')} 59 |
60 |
61 |
65 | {comments.map(id => ).toArray()} 66 |
67 |
68 | ); 69 | } 70 | } 71 | 72 | HackerNewsCommentInner.propTypes = propTypes; 73 | HackerNewsCommentInner.defaultProps = defaultProps; 74 | 75 | const makeMapStateToProps = () => { 76 | const getChildrenComments = makeGetChildrenComments(); 77 | const getCommentContents = makeGetCommentContents(); 78 | 79 | return (state, props) => ({ 80 | comments: getChildrenComments(state, props), 81 | contents: getCommentContents(state, props), 82 | }); 83 | }; 84 | 85 | const HackerNewsComment = connect(makeMapStateToProps, null)(HackerNewsCommentInner); 86 | export default HackerNewsComment; 87 | -------------------------------------------------------------------------------- /app/containers/HackerNewsItemContainer.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { withDone } from 'react-router-server'; 3 | 4 | import HackerNewsItem from 'components/HackerNewsItem'; 5 | import { fetchHackerComments } from 'store/actions'; 6 | import { getItem, getChildrenComments } from 'store/selectors'; 7 | 8 | const mapStateToProps = (state, props) => ({ 9 | comments: getChildrenComments(state, props), 10 | item: getItem(state, props), 11 | }); 12 | 13 | const mapDispatchToProps = { 14 | onItemFetch: fetchHackerComments, 15 | }; 16 | 17 | export default withDone(connect( 18 | mapStateToProps, 19 | mapDispatchToProps, 20 | )(HackerNewsItem)); 21 | -------------------------------------------------------------------------------- /app/containers/HackerNewsListContainer.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import HackerNewsList from 'components/HackerNewsList'; 4 | import { getFeeds, getIsFetching } from 'store/selectors'; 5 | 6 | const mapStateToProps = (state, props) => ({ 7 | feeds: getFeeds(state, props), 8 | isFetching: getIsFetching(state), 9 | }); 10 | 11 | export default connect(mapStateToProps)(HackerNewsList); 12 | -------------------------------------------------------------------------------- /app/containers/HackerNewsUserContainer.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { withDone } from 'react-router-server'; 3 | 4 | import { fetchHackerUser } from 'store/actions'; 5 | import { getSpecificUser } from 'store/selectors'; 6 | import HackerNewsUser from 'components/HackerNewsUser'; 7 | 8 | const mapStateToProps = (state, props) => ({ 9 | information: getSpecificUser(state, props), 10 | }); 11 | 12 | const mapDispatchToProps = { 13 | onUserFetch: fetchHackerUser, 14 | }; 15 | 16 | export default withDone(connect( 17 | mapStateToProps, 18 | mapDispatchToProps, 19 | )(HackerNewsUser)); 20 | -------------------------------------------------------------------------------- /app/containers/LocationPaginationContainer.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { withRouter } from 'react-router-dom'; 3 | import { withDone } from 'react-router-server'; 4 | 5 | import LocationPagination from 'components/LocationPagination'; 6 | import { fetchHackerNews } from 'store/actions'; 7 | import { getCurrentPage, getFeedCount } from 'store/selectors'; 8 | 9 | const mapStateToProps = (state, props) => ({ 10 | currentPage: getCurrentPage(state), 11 | feedCount: getFeedCount(state, props), 12 | }); 13 | 14 | const mapDispatchToProps = { 15 | onPaginate: fetchHackerNews, 16 | }; 17 | 18 | export default withDone(withRouter(connect( 19 | mapStateToProps, 20 | mapDispatchToProps, 21 | )(LocationPagination))); 22 | -------------------------------------------------------------------------------- /app/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HNPWA with React 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | <% for (var css in htmlWebpackPlugin.files.css) { %> 39 | 40 | <% } %> 41 | 42 | 43 | <%= htmlWebpackPlugin.options.markup %> 44 | 45 | <% for (var chunk in htmlWebpackPlugin.files.chunks) { %> 46 | 47 | <% } %> 48 | 49 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /app/pages/Feed/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import HackerNewsListContainer from 'containers/HackerNewsListContainer'; 5 | import LocationPaginationContainer from 'containers/LocationPaginationContainer'; 6 | 7 | const propTypes = { 8 | location: PropTypes.shape({ pathname: PropTypes.string }).isRequired, 9 | }; 10 | 11 | function FeedRoute({ location }) { 12 | const feedType = location.pathname.split('/').slice(1)[0]; 13 | 14 | return ( 15 |
16 | 17 |
18 | 19 |
20 |
21 | ); 22 | } 23 | 24 | FeedRoute.propTypes = propTypes; 25 | 26 | export default FeedRoute; 27 | -------------------------------------------------------------------------------- /app/pages/Item/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import HackerNewsItemContainer from 'containers/HackerNewsItemContainer'; 5 | 6 | const propTypes = { 7 | match: PropTypes.shape({ params: PropTypes.shape({ id: PropTypes.string }) }).isRequired, 8 | }; 9 | 10 | function ItemRoute({ match }) { 11 | const id = parseInt(match.params.id, 10); 12 | 13 | return ( 14 |
15 | 16 |
17 | ); 18 | } 19 | 20 | ItemRoute.propTypes = propTypes; 21 | 22 | export default ItemRoute; 23 | -------------------------------------------------------------------------------- /app/pages/User/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import HackerNewsUserContainer from 'containers/HackerNewsUserContainer'; 5 | 6 | const propTypes = { 7 | match: PropTypes.shape({ params: PropTypes.shape({ id: PropTypes.string }) }).isRequired, 8 | }; 9 | 10 | function UserRoute({ match }) { 11 | return ( 12 |
13 | 14 |
15 | ); 16 | } 17 | 18 | UserRoute.propTypes = propTypes; 19 | 20 | export default UserRoute; 21 | -------------------------------------------------------------------------------- /app/pages/index.js: -------------------------------------------------------------------------------- 1 | export { default as Feed } from './Feed'; 2 | export { default as Item } from './Item'; 3 | export { default as User } from './User'; 4 | -------------------------------------------------------------------------------- /app/scss/base/mixins/_mixins.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwanno/hnpwa-react/264d72bdea97ccee76d8f370b8a89f099d246603/app/scss/base/mixins/_mixins.scss -------------------------------------------------------------------------------- /app/scss/base/typography.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 3 | font-size: 15px; 4 | } 5 | -------------------------------------------------------------------------------- /app/scss/base/variables/_colors.scss: -------------------------------------------------------------------------------- 1 | $color-black: #000; 2 | $color-main: #f4751e; 3 | $color-gray: #808080; 4 | $color-shadow: rgba(0, 0, 0, .1); 5 | $color-white: #fff; 6 | -------------------------------------------------------------------------------- /app/scss/base/variables/_sizes.scss: -------------------------------------------------------------------------------- 1 | $header-height: 50px; 2 | -------------------------------------------------------------------------------- /app/scss/style.scss: -------------------------------------------------------------------------------- 1 | @import "~normalize.css/normalize.css"; 2 | 3 | @import "base/typography"; 4 | 5 | .content-container { 6 | margin: 0 auto; 7 | max-width: 800px; 8 | } 9 | -------------------------------------------------------------------------------- /app/server.jsx: -------------------------------------------------------------------------------- 1 | import createMemoryHistory from 'history/createMemoryHistory'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { ConnectedRouter, routerMiddleware } from 'react-router-redux'; 5 | import { renderToString } from 'react-router-server'; 6 | 7 | import AppShell from 'components/AppShell'; 8 | import configureStore from 'store/configureStore'; 9 | 10 | function render(location) { 11 | const history = createMemoryHistory({ initialEntries: [location] }); 12 | const store = configureStore({ routerMiddleware: routerMiddleware(history) }); 13 | 14 | return renderToString(( 15 | 16 | 17 | 18 | 19 | 20 | )).then(({ html }) => ({ markup: html, state: store.getState() })); 21 | } 22 | 23 | export default render; 24 | -------------------------------------------------------------------------------- /app/store/__tests__/actions.spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import nock from 'nock'; 3 | import configureMockStore from 'redux-mock-store'; 4 | import thunk from 'redux-thunk'; 5 | 6 | import * as actions from '../actions'; 7 | import * as ACTIONS from '../actionTypes'; 8 | import flattenComments from '../flattenComments'; 9 | 10 | const middlewares = [thunk]; 11 | const mockStore = configureMockStore(middlewares); 12 | 13 | describe('actions', () => { 14 | describe('HACKER_COMMENTS_FETCH_* action creators', () => { 15 | it('should create an action to fetch hacker comments request', () => { 16 | const id = 1234; 17 | const expectedAction = { 18 | type: ACTIONS.HACKER_COMMENTS_FETCH_REQUEST, 19 | payload: id, 20 | }; 21 | expect(actions.hackerCommentsFetchRequest(id)).toEqual(expectedAction); 22 | }); 23 | 24 | it('should create an action to fetch hacker user success', () => { 25 | const data = {}; 26 | const expectedAction = { 27 | type: ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS, 28 | payload: data, 29 | }; 30 | expect(actions.hackerCommentsFetchSuccess(data)).toEqual(expectedAction); 31 | }); 32 | 33 | it('should create an action to fetch hacker user failure', () => { 34 | const error = new Error(); 35 | const expectedAction = { 36 | type: ACTIONS.HACKER_COMMENTS_FETCH_FAILURE, 37 | payload: error, 38 | error: true, 39 | }; 40 | expect(actions.hackerCommentsFetchFailure(error)).toEqual(expectedAction); 41 | }); 42 | 43 | describe('async actions', () => { 44 | afterEach(() => { 45 | nock.cleanAll(); 46 | }); 47 | 48 | it('creates HACKER_COMMENTS_FETCH_SUCCESS when fetching comments has been done', () => { 49 | const id = 1234; 50 | const responseBody = { 51 | id: 3338485, 52 | title: 'Lamest bug we ever encountered', 53 | points: 76, 54 | user: 'exch', 55 | time: 1323550709, 56 | time_ago: '6 years ago', 57 | type: 'link', 58 | url: 'http://joostdevblog.blogspot.com/2011/12/lamest-bug-we-ever-encountered.html', 59 | domain: 'joostdevblog.blogspot.com', 60 | comments: [ 61 | { 62 | id: 3338903, 63 | level: 0, 64 | user: 'akg', 65 | time: 1323563056, 66 | time_ago: '6 years ago', 67 | content: '

Reminds me of the time I had written a physical simulation engine back in grad school and there was a "minus" sign error. Of course, the error was rare enough that we didn\'t notice it until after the code was used in a real production environment. Tracking down one minus sign in several hundred thousands of lines is a pain. Not to mention the uneasy feeling you get after you solve it, "How was everything ever working correctly before!? What else did we overlook?"', 68 | comments: [ 69 | { 70 | id: 3339842, 71 | level: 1, 72 | user: 'Confusion', 73 | time: 1323601529, 74 | time_ago: '6 years ago', 75 | content: '

If I have to venture a guess, I guess you didn\'t have a comprehensive set of tests at the function/method level of the code? Having that would probably have caught the bug, because you would have written a test for correctly executing the code in that branch.', 76 | comments: [ 77 | { 78 | id: 3340746, 79 | level: 2, 80 | user: 'akg', 81 | time: 1323630681, 82 | time_ago: '6 years ago', 83 | content: '

You\'re right. But it was after that pain-staking experience that I became fully engrossed in using unittests for all non-trivial functionality. Live and learn.', 84 | comments: [], 85 | }, 86 | ], 87 | }, 88 | ], 89 | }, 90 | { 91 | id: 3339723, 92 | level: 0, 93 | user: 'TwoBit', 94 | time: 1323596319, 95 | time_ago: '6 years ago', 96 | content: '

They could have solved that bug with one developer in ten minutes by just telling the PS3 to generate a core dump and running addr2line.exe on the core dump report\'s callstacks.

And the report places the blame on the server instead of their code. Clearly it\'s their code\'s fault for doing blocking sockets calls in a main thread.', 97 | comments: [], 98 | }, 99 | ], 100 | comments_count: 23, 101 | }; 102 | 103 | nock('https://node-hnapi.herokuapp.com/') 104 | .get(`/item/${id}`) 105 | .reply(200, responseBody); 106 | 107 | const expectedActions = [ 108 | { 109 | type: ACTIONS.HACKER_COMMENTS_FETCH_REQUEST, 110 | payload: id, 111 | }, 112 | { 113 | type: ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS, 114 | payload: { 115 | ...responseBody, 116 | timeAgo: responseBody.time_ago, 117 | comments: flattenComments(responseBody).map(v => ({ ...v, timeAgo: v.time_ago })), 118 | }, 119 | }, 120 | ]; 121 | const store = mockStore({ comments: Immutable.Map() }); 122 | 123 | return store.dispatch(actions.fetchHackerComments(id)).then(() => { 124 | expect(store.getActions()).toEqual(expectedActions); 125 | }); 126 | }); 127 | 128 | it('creates HACKER_COMMENTS_FETCH_FAILURE when fetching comments has been failed', () => { 129 | const id = 1234; 130 | 131 | nock('https://node-hnapi.herokuapp.com/') 132 | .get(`/item/${id}`) 133 | .reply(400); 134 | 135 | const expectedActions = [ 136 | { 137 | type: ACTIONS.HACKER_COMMENTS_FETCH_REQUEST, 138 | payload: id, 139 | }, 140 | { 141 | type: ACTIONS.HACKER_COMMENTS_FETCH_FAILURE, 142 | payload: new Error('Bad Request'), 143 | error: true, 144 | }, 145 | ]; 146 | const store = mockStore({ user: Immutable.Map() }); 147 | 148 | return store.dispatch(actions.fetchHackerComments(id)).catch(() => { 149 | expect(store.getActions()).toEqual(expectedActions); 150 | }); 151 | }); 152 | }); 153 | }); 154 | 155 | describe('HACKER_NEWS_FETCH_* action creators', () => { 156 | it('should create an action to fetch hacker news request', () => { 157 | const type = 'news'; 158 | const page = 1; 159 | const expectedAction = { 160 | type: ACTIONS.HACKER_NEWS_FETCH_REQUEST, 161 | payload: { 162 | type, 163 | page, 164 | }, 165 | }; 166 | expect(actions.hackerNewsFetchRequest(type, page)).toEqual(expectedAction); 167 | }); 168 | 169 | it('should create an action to fetch hacker news success', () => { 170 | const type = 'news'; 171 | const page = 1; 172 | const data = []; 173 | const expectedAction = { 174 | type: ACTIONS.HACKER_NEWS_FETCH_SUCCESS, 175 | payload: { 176 | type, 177 | page, 178 | data, 179 | }, 180 | }; 181 | expect(actions.hackerNewsFetchSuccess(type, page, data)).toEqual(expectedAction); 182 | }); 183 | 184 | it('should create an action to fetch hacker news failure', () => { 185 | const error = new Error(); 186 | const expectedAction = { 187 | type: ACTIONS.HACKER_NEWS_FETCH_FAILURE, 188 | payload: error, 189 | error: true, 190 | }; 191 | expect(actions.hackerNewsFetchFailure(error)).toEqual(expectedAction); 192 | }); 193 | 194 | describe('async actions', () => { 195 | afterEach(() => { 196 | nock.cleanAll(); 197 | }); 198 | 199 | it('creates HACKER_NEWS_FETCH_SUCCESS when fetching news has been done', () => { 200 | const type = 'news'; 201 | const page = 1; 202 | 203 | nock('https://node-hnapi.herokuapp.com/') 204 | .get(`/${type}?page=${page}`) 205 | .reply(200, [{ 206 | comments_count: 116, 207 | domain: 'blog.ycombinator.com', 208 | id: 15348384, 209 | points: 140, 210 | time: 1506524153, 211 | time_ago: '2 hours ago', 212 | title: 'Interview with Mr. Money Mustache', 213 | type: 'link', 214 | url: 'https://blog.ycombinator.com/dont-start-a-blog-start-a-cult-mr-money-mustache/', 215 | user: 'craigcannon', 216 | }]); 217 | 218 | const expectedActions = [ 219 | { 220 | type: ACTIONS.HACKER_NEWS_FETCH_REQUEST, 221 | payload: { type, page }, 222 | }, 223 | { 224 | type: ACTIONS.HACKER_NEWS_FETCH_SUCCESS, 225 | payload: { 226 | type, 227 | page, 228 | data: [{ 229 | commentsCount: 116, 230 | domain: 'blog.ycombinator.com', 231 | id: 15348384, 232 | points: 140, 233 | time: 1506524153, 234 | timeAgo: '2 hours ago', 235 | title: 'Interview with Mr. Money Mustache', 236 | type: 'link', 237 | url: 'https://blog.ycombinator.com/dont-start-a-blog-start-a-cult-mr-money-mustache/', 238 | user: 'craigcannon', 239 | }], 240 | }, 241 | }, 242 | ]; 243 | const store = mockStore({ 244 | byId: {}, 245 | currentPage: 1, 246 | items: {}, 247 | }); 248 | 249 | return store.dispatch(actions.fetchHackerNews(type, page)).then(() => { 250 | expect(store.getActions()).toEqual(expectedActions); 251 | }); 252 | }); 253 | 254 | it('creates HACKER_NEWS_FETCH_FAILURE when fetching news has been failed', () => { 255 | const type = 'news'; 256 | const page = 1; 257 | 258 | nock('https://node-hnapi.herokuapp.com/') 259 | .get(`/${type}?page=${page}`) 260 | .reply(400); 261 | 262 | const expectedActions = [ 263 | { 264 | type: ACTIONS.HACKER_NEWS_FETCH_REQUEST, 265 | payload: { type, page }, 266 | }, 267 | { 268 | type: ACTIONS.HACKER_NEWS_FETCH_FAILURE, 269 | payload: new Error('Bad Request'), 270 | error: true, 271 | }, 272 | ]; 273 | const store = mockStore({ 274 | byId: {}, 275 | currentPage: 1, 276 | items: {}, 277 | }); 278 | 279 | return store.dispatch(actions.fetchHackerNews(type, page)).catch(() => { 280 | expect(store.getActions()).toEqual(expectedActions); 281 | }); 282 | }); 283 | }); 284 | }); 285 | 286 | describe('HACKER_USER_FETCH_* action creators', () => { 287 | it('should create an action to fetch hacker user request', () => { 288 | const id = 'taehwanno'; 289 | const expectedAction = { 290 | type: ACTIONS.HACKER_USER_FETCH_REQUEST, 291 | payload: id, 292 | }; 293 | expect(actions.hackerUserFetchRequest(id)).toEqual(expectedAction); 294 | }); 295 | 296 | it('should create an action to fetch hacker user success', () => { 297 | const data = {}; 298 | const expectedAction = { 299 | type: ACTIONS.HACKER_USER_FETCH_SUCCESS, 300 | payload: data, 301 | }; 302 | expect(actions.hackerUserFetchSuccess(data)).toEqual(expectedAction); 303 | }); 304 | 305 | it('should create an action to fetch hacker user failure', () => { 306 | const error = new Error(); 307 | const expectedAction = { 308 | type: ACTIONS.HACKER_USER_FETCH_FAILURE, 309 | payload: error, 310 | error: true, 311 | }; 312 | expect(actions.hackerUserFetchFailure(error)).toEqual(expectedAction); 313 | }); 314 | 315 | describe('async actions', () => { 316 | afterEach(() => { 317 | nock.cleanAll(); 318 | }); 319 | 320 | it('creates HACKER_USER_FETCH_SUCCESS when fetching user has been done', () => { 321 | const id = 'taehwanno'; 322 | 323 | nock('https://node-hnapi.herokuapp.com/') 324 | .get(`/user/${id}`) 325 | .reply(200, { 326 | id, 327 | created_time: 1284124124, 328 | created: '7 years ago', 329 | karma: 1224, 330 | avg: null, 331 | about: null, 332 | }); 333 | 334 | const expectedActions = [ 335 | { 336 | type: ACTIONS.HACKER_USER_FETCH_REQUEST, 337 | payload: id, 338 | }, 339 | { 340 | type: ACTIONS.HACKER_USER_FETCH_SUCCESS, 341 | payload: { 342 | id, 343 | created_time: 1284124124, 344 | created: '7 years ago', 345 | karma: 1224, 346 | avg: null, 347 | about: null, 348 | }, 349 | }, 350 | ]; 351 | const store = mockStore({ user: Immutable.Map() }); 352 | 353 | return store.dispatch(actions.fetchHackerUser(id)).then(() => { 354 | expect(store.getActions()).toEqual(expectedActions); 355 | }); 356 | }); 357 | 358 | it('creates HACKER_USER_FETCH_FAILURE when fetching user has been failed', () => { 359 | const id = 'taehwanno'; 360 | 361 | nock('https://node-hnapi.herokuapp.com/') 362 | .get(`/user/${id}`) 363 | .reply(400); 364 | 365 | const expectedActions = [ 366 | { 367 | type: ACTIONS.HACKER_USER_FETCH_REQUEST, 368 | payload: id, 369 | }, 370 | { 371 | type: ACTIONS.HACKER_USER_FETCH_FAILURE, 372 | payload: new Error('Bad Request'), 373 | error: true, 374 | }, 375 | ]; 376 | const store = mockStore({ user: Immutable.Map() }); 377 | 378 | return store.dispatch(actions.fetchHackerUser(id)).catch(() => { 379 | expect(store.getActions()).toEqual(expectedActions); 380 | }); 381 | }); 382 | }); 383 | }); 384 | }); 385 | -------------------------------------------------------------------------------- /app/store/__tests__/flattenComments.spec.js: -------------------------------------------------------------------------------- 1 | import flattenComments from '../flattenComments'; 2 | 3 | describe('flattenComments', () => { 4 | it('should return flatComments', () => { 5 | expect(flattenComments({ 6 | id: 3338485, 7 | title: 'Lamest bug we ever encountered', 8 | points: 76, 9 | user: 'exch', 10 | time: 1323550709, 11 | time_ago: '6 years ago', 12 | type: 'link', 13 | url: 'http://joostdevblog.blogspot.com/2011/12/lamest-bug-we-ever-encountered.html', 14 | domain: 'joostdevblog.blogspot.com', 15 | comments: [ 16 | { 17 | id: 3338903, 18 | level: 0, 19 | user: 'akg', 20 | time: 1323563056, 21 | time_ago: '6 years ago', 22 | content: '

Reminds me of the time I had written a physical simulation engine back in grad school and there was a "minus" sign error. Of course, the error was rare enough that we didn\'t notice it until after the code was used in a real production environment. Tracking down one minus sign in several hundred thousands of lines is a pain. Not to mention the uneasy feeling you get after you solve it, "How was everything ever working correctly before!? What else did we overlook?"', 23 | comments: [ 24 | { 25 | id: 3339842, 26 | level: 1, 27 | user: 'Confusion', 28 | time: 1323601529, 29 | time_ago: '6 years ago', 30 | content: '

If I have to venture a guess, I guess you didn\'t have a comprehensive set of tests at the function/method level of the code? Having that would probably have caught the bug, because you would have written a test for correctly executing the code in that branch.', 31 | comments: [ 32 | { 33 | id: 3340746, 34 | level: 2, 35 | user: 'akg', 36 | time: 1323630681, 37 | time_ago: '6 years ago', 38 | content: '

You\'re right. But it was after that pain-staking experience that I became fully engrossed in using unittests for all non-trivial functionality. Live and learn.', 39 | comments: [], 40 | }, 41 | ], 42 | }, 43 | ], 44 | }, 45 | { 46 | id: 3339723, 47 | level: 0, 48 | user: 'TwoBit', 49 | time: 1323596319, 50 | time_ago: '6 years ago', 51 | content: '

They could have solved that bug with one developer in ten minutes by just telling the PS3 to generate a core dump and running addr2line.exe on the core dump report\'s callstacks.

And the report places the blame on the server instead of their code. Clearly it\'s their code\'s fault for doing blocking sockets calls in a main thread.', 52 | comments: [], 53 | }, 54 | ], 55 | comments_count: 23, 56 | })).toEqual([ 57 | { 58 | id: 3340746, 59 | level: 2, 60 | user: 'akg', 61 | time: 1323630681, 62 | time_ago: '6 years ago', 63 | content: '

You\'re right. But it was after that pain-staking experience that I became fully engrossed in using unittests for all non-trivial functionality. Live and learn.', 64 | comments: [], 65 | parent: 3339842, 66 | }, 67 | { 68 | id: 3339842, 69 | level: 1, 70 | user: 'Confusion', 71 | time: 1323601529, 72 | time_ago: '6 years ago', 73 | content: '

If I have to venture a guess, I guess you didn\'t have a comprehensive set of tests at the function/method level of the code? Having that would probably have caught the bug, because you would have written a test for correctly executing the code in that branch.', 74 | comments: [3340746], 75 | parent: 3338903, 76 | }, 77 | { 78 | id: 3338903, 79 | level: 0, 80 | user: 'akg', 81 | time: 1323563056, 82 | time_ago: '6 years ago', 83 | content: '

Reminds me of the time I had written a physical simulation engine back in grad school and there was a "minus" sign error. Of course, the error was rare enough that we didn\'t notice it until after the code was used in a real production environment. Tracking down one minus sign in several hundred thousands of lines is a pain. Not to mention the uneasy feeling you get after you solve it, "How was everything ever working correctly before!? What else did we overlook?"', 84 | comments: [3339842], 85 | parent: 3338485, 86 | }, 87 | { 88 | id: 3339723, 89 | level: 0, 90 | user: 'TwoBit', 91 | time: 1323596319, 92 | time_ago: '6 years ago', 93 | content: '

They could have solved that bug with one developer in ten minutes by just telling the PS3 to generate a core dump and running addr2line.exe on the core dump report\'s callstacks.

And the report places the blame on the server instead of their code. Clearly it\'s their code\'s fault for doing blocking sockets calls in a main thread.', 94 | comments: [], 95 | parent: 3338485, 96 | }]); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /app/store/__tests__/reducers/byId.spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | import * as ACTIONS from 'store/actionTypes'; 4 | import byId from 'store/byId'; 5 | 6 | describe('byId reducer', () => { 7 | it('should return the initial state', () => { 8 | expect(byId(undefined, {})).toEqual(Immutable.Map()); 9 | }); 10 | 11 | it('should handle HACKER_NEWS_FETCH_SUCCESS', () => { 12 | const secondState = Immutable.Map(new Map([ 13 | [ 14 | 1, Immutable.Map({ 15 | id: 1, 16 | title: 'Interview with Mr. Money Mustache', 17 | }), 18 | ]])); 19 | 20 | expect(byId(Immutable.Map(), { 21 | type: ACTIONS.HACKER_NEWS_FETCH_SUCCESS, 22 | payload: { 23 | data: [ 24 | { 25 | id: 1, 26 | title: 'Interview with Mr. Money Mustache', 27 | }, 28 | ], 29 | }, 30 | })).toEqual(secondState); 31 | 32 | expect(byId(secondState, { 33 | type: ACTIONS.HACKER_NEWS_FETCH_SUCCESS, 34 | payload: { 35 | data: [ 36 | { 37 | id: 2, 38 | title: 'React with TypeScript', 39 | }, 40 | ], 41 | }, 42 | })).toEqual(secondState.set( 43 | 2, 44 | Immutable.Map({ 45 | id: 2, 46 | title: 'React with TypeScript', 47 | }), 48 | )); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /app/store/__tests__/reducers/comments/byId.spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | import * as ACTIONS from 'store/actionTypes'; 4 | import byId from 'store/comments/byId'; 5 | 6 | describe('comments.byId reducer', () => { 7 | it('should return the initial state', () => { 8 | expect(byId(undefined, {})).toEqual(Immutable.Map()); 9 | }); 10 | 11 | it('should handle HACKER_COMMENTS_FETCH_SUCCESS', () => { 12 | expect(byId(undefined, { 13 | type: ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS, 14 | payload: { 15 | id: 3338485, 16 | title: 'Lamest bug we ever encountered', 17 | points: 76, 18 | user: 'exch', 19 | time: 1323550709, 20 | time_ago: '6 years ago', 21 | timeAgo: '6 years ago', 22 | type: 'link', 23 | url: 'http://joostdevblog.blogspot.com/2011/12/lamest-bug-we-ever-encountered.html', 24 | domain: 'joostdevblog.blogspot.com', 25 | comments_count: 23, 26 | commentsCount: 23, 27 | comments: [ 28 | { 29 | id: 3340746, 30 | level: 2, 31 | user: 'akg', 32 | time: 1323630681, 33 | time_ago: '6 years ago', 34 | timeAgo: '6 years ago', 35 | content: '

You\'re right. But it was after that pain-staking experience that I became fully engrossed in using unittests for all non-trivial functionality. Live and learn.', 36 | comments: [], 37 | parent: 3339842, 38 | }, 39 | { 40 | id: 3339842, 41 | level: 1, 42 | user: 'Confusion', 43 | time: 1323601529, 44 | time_ago: '6 years ago', 45 | content: '

If I have to venture a guess, I guess you didn\'t have a comprehensive set of tests at the function/method level of the code? Having that would probably have caught the bug, because you would have written a test for correctly executing the code in that branch.', 46 | comments: [3340746], 47 | parent: 3338903, 48 | }, 49 | { 50 | id: 3338903, 51 | level: 0, 52 | user: 'akg', 53 | time: 1323563056, 54 | time_ago: '6 years ago', 55 | timeAgo: '6 years ago', 56 | content: '

Reminds me of the time I had written a physical simulation engine back in grad school and there was a "minus" sign error. Of course, the error was rare enough that we didn\'t notice it until after the code was used in a real production environment. Tracking down one minus sign in several hundred thousands of lines is a pain. Not to mention the uneasy feeling you get after you solve it, "How was everything ever working correctly before!? What else did we overlook?"', 57 | comments: [3339842], 58 | parent: 3338485, 59 | }, 60 | { 61 | id: 3339723, 62 | level: 0, 63 | user: 'TwoBit', 64 | time: 1323596319, 65 | time_ago: '6 years ago', 66 | timeAgo: '6 years ago', 67 | content: '

They could have solved that bug with one developer in ten minutes by just telling the PS3 to generate a core dump and running addr2line.exe on the core dump report\'s callstacks.

And the report places the blame on the server instead of their code. Clearly it\'s their code\'s fault for doing blocking sockets calls in a main thread.', 68 | comments: [], 69 | parent: 3338485, 70 | }, 71 | ], 72 | }, 73 | })).toEqual(Immutable.Map(new Map([ 74 | [3340746, Immutable.Map({ 75 | id: 3340746, 76 | level: 2, 77 | user: 'akg', 78 | time: 1323630681, 79 | time_ago: '6 years ago', 80 | timeAgo: '6 years ago', 81 | content: '

You\'re right. But it was after that pain-staking experience that I became fully engrossed in using unittests for all non-trivial functionality. Live and learn.', 82 | comments: Immutable.List(), 83 | parent: 3339842, 84 | })], 85 | [3339842, Immutable.Map({ 86 | id: 3339842, 87 | level: 1, 88 | user: 'Confusion', 89 | time: 1323601529, 90 | time_ago: '6 years ago', 91 | content: '

If I have to venture a guess, I guess you didn\'t have a comprehensive set of tests at the function/method level of the code? Having that would probably have caught the bug, because you would have written a test for correctly executing the code in that branch.', 92 | comments: Immutable.List([3340746]), 93 | parent: 3338903, 94 | })], 95 | [3338903, Immutable.Map({ 96 | id: 3338903, 97 | level: 0, 98 | user: 'akg', 99 | time: 1323563056, 100 | time_ago: '6 years ago', 101 | timeAgo: '6 years ago', 102 | content: '

Reminds me of the time I had written a physical simulation engine back in grad school and there was a "minus" sign error. Of course, the error was rare enough that we didn\'t notice it until after the code was used in a real production environment. Tracking down one minus sign in several hundred thousands of lines is a pain. Not to mention the uneasy feeling you get after you solve it, "How was everything ever working correctly before!? What else did we overlook?"', 103 | comments: Immutable.List([3339842]), 104 | parent: 3338485, 105 | })], 106 | [3339723, Immutable.Map({ 107 | id: 3339723, 108 | level: 0, 109 | user: 'TwoBit', 110 | time: 1323596319, 111 | time_ago: '6 years ago', 112 | timeAgo: '6 years ago', 113 | content: '

They could have solved that bug with one developer in ten minutes by just telling the PS3 to generate a core dump and running addr2line.exe on the core dump report\'s callstacks.

And the report places the blame on the server instead of their code. Clearly it\'s their code\'s fault for doing blocking sockets calls in a main thread.', 114 | comments: Immutable.List(), 115 | parent: 3338485, 116 | })], 117 | [3338485, Immutable.Map({ 118 | id: 3338485, 119 | title: 'Lamest bug we ever encountered', 120 | points: 76, 121 | user: 'exch', 122 | time: 1323550709, 123 | time_ago: '6 years ago', 124 | timeAgo: '6 years ago', 125 | type: 'link', 126 | url: 'http://joostdevblog.blogspot.com/2011/12/lamest-bug-we-ever-encountered.html', 127 | domain: 'joostdevblog.blogspot.com', 128 | comments_count: 23, 129 | commentsCount: 23, 130 | comments: Immutable.List([3338903, 3339723]), 131 | })], 132 | ]))); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /app/store/__tests__/reducers/comments/posts.spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | import * as ACTIONS from 'store/actionTypes'; 4 | import posts from 'store/comments/posts'; 5 | 6 | describe('comments.posts reducer', () => { 7 | it('should return the initial state', () => { 8 | expect(posts(undefined, {})).toEqual(Immutable.Map()); 9 | }); 10 | 11 | it('should handle HACKER_COMMENTS_FETCH_SUCCESS', () => { 12 | expect(posts(undefined, { 13 | type: ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS, 14 | payload: { 15 | comments_count: 40, 16 | id: 3338485, 17 | title: 'Lamest bug we ever encountered', 18 | points: 76, 19 | user: 'exch', 20 | time: 1323550709, 21 | time_ago: '6 years ago', 22 | type: 'link', 23 | url: 'http://joostdevblog.blogspot.com/2011/12/lamest-bug-we-ever-encountered.html', 24 | domain: 'joostdevblog.blogspot.com', 25 | }, 26 | })).toEqual(Immutable.Map(new Map([ 27 | [3338485, Immutable.Map({ 28 | commentsCount: 40, 29 | id: 3338485, 30 | title: 'Lamest bug we ever encountered', 31 | points: 76, 32 | user: 'exch', 33 | time: 1323550709, 34 | timeAgo: '6 years ago', 35 | type: 'link', 36 | url: 'http://joostdevblog.blogspot.com/2011/12/lamest-bug-we-ever-encountered.html', 37 | domain: 'joostdevblog.blogspot.com', 38 | })], 39 | ]))); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /app/store/__tests__/reducers/currentPage.spec.js: -------------------------------------------------------------------------------- 1 | import * as ACTIONS from 'store/actionTypes'; 2 | import currentPage from 'store/currentPage'; 3 | 4 | describe('currentPage reducer', () => { 5 | it('should return the initial state', () => { 6 | expect(currentPage(undefined, {})).toBe(1); 7 | }); 8 | 9 | it('should handle HACKER_NEWS_FETCH_REQUEST', () => { 10 | expect(currentPage(1, { 11 | type: ACTIONS.HACKER_NEWS_FETCH_REQUEST, 12 | payload: { 13 | page: 2, 14 | }, 15 | })).toBe(2); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /app/store/__tests__/reducers/isFetching.spec.js: -------------------------------------------------------------------------------- 1 | import * as ACTIONS from 'store/actionTypes'; 2 | import isFetching from 'store/isFetching'; 3 | 4 | describe('isFetching reducer', () => { 5 | it('should return the initial state', () => { 6 | expect(isFetching(undefined, {})).toBe(false); 7 | }); 8 | 9 | it('should handle HACKER_COMMENTS_FETCH_REQUEST', () => { 10 | expect(isFetching(false, { 11 | type: ACTIONS.HACKER_COMMENTS_FETCH_REQUEST, 12 | })).toBe(true); 13 | }); 14 | 15 | it('should handle HACKER_COMMENTS_FETCH_SUCCESS', () => { 16 | expect(isFetching(true, { 17 | type: ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS, 18 | })).toBe(false); 19 | }); 20 | 21 | it('should handle HACKER_COMMENTS_FETCH_FAILURE', () => { 22 | expect(isFetching(true, { 23 | type: ACTIONS.HACKER_COMMENTS_FETCH_FAILURE, 24 | })).toBe(false); 25 | }); 26 | 27 | it('should handle HACKER_NEWS_FETCH_REQUEST', () => { 28 | expect(isFetching(false, { 29 | type: ACTIONS.HACKER_NEWS_FETCH_REQUEST, 30 | })).toBe(true); 31 | }); 32 | 33 | it('should handle HACKER_NEWS_FETCH_SUCCESS', () => { 34 | expect(isFetching(true, { 35 | type: ACTIONS.HACKER_NEWS_FETCH_SUCCESS, 36 | })).toBe(false); 37 | }); 38 | 39 | it('should handle HACKER_NEWS_FETCH_FAILURE', () => { 40 | expect(isFetching(true, { 41 | type: ACTIONS.HACKER_NEWS_FETCH_FAILURE, 42 | })).toBe(false); 43 | }); 44 | 45 | it('should handle HACKER_USER_FETCH_REQUEST', () => { 46 | expect(isFetching(false, { 47 | type: ACTIONS.HACKER_USER_FETCH_REQUEST, 48 | })).toBe(true); 49 | }); 50 | 51 | it('should handle HACKER_USER_FETCH_SUCCESS', () => { 52 | expect(isFetching(true, { 53 | type: ACTIONS.HACKER_USER_FETCH_SUCCESS, 54 | })).toBe(false); 55 | }); 56 | 57 | it('should handle HACKER_USER_FETCH_FAILURE', () => { 58 | expect(isFetching(true, { 59 | type: ACTIONS.HACKER_USER_FETCH_FAILURE, 60 | })).toBe(false); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /app/store/__tests__/reducers/items.spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | import * as ACTIONS from 'store/actionTypes'; 4 | import items from 'store/items'; 5 | 6 | describe('items reducer', () => { 7 | it('should return the initial state', () => { 8 | expect(items(undefined, {})).toEqual(Immutable.fromJS({ 9 | ask: {}, 10 | jobs: {}, 11 | newest: {}, 12 | news: {}, 13 | show: {}, 14 | })); 15 | }); 16 | 17 | it('should handle HACKER_NEWS_FETCH_SUCCESS', () => { 18 | expect(items(undefined, { 19 | type: ACTIONS.HACKER_NEWS_FETCH_SUCCESS, 20 | payload: { 21 | type: 'ask', 22 | page: 1, 23 | data: [ 24 | { 25 | id: 10, 26 | }, 27 | ], 28 | }, 29 | })).toEqual(Immutable.fromJS({ 30 | ask: Immutable.Map(new Map([ 31 | [1, Immutable.List([10])], 32 | ])), 33 | jobs: {}, 34 | newest: {}, 35 | news: {}, 36 | show: {}, 37 | })); 38 | 39 | expect(items(Immutable.fromJS({ 40 | ask: Immutable.Map(new Map([ 41 | [1, Immutable.List([10])], 42 | ])), 43 | jobs: {}, 44 | newest: {}, 45 | news: {}, 46 | show: {}, 47 | }), { 48 | type: ACTIONS.HACKER_NEWS_FETCH_SUCCESS, 49 | payload: { 50 | type: 'jobs', 51 | page: 1, 52 | data: [ 53 | { 54 | id: 11, 55 | }, 56 | ], 57 | }, 58 | })).toEqual(Immutable.fromJS({ 59 | ask: Immutable.Map(new Map([ 60 | [1, Immutable.List([10])], 61 | ])), 62 | jobs: Immutable.Map(new Map([ 63 | [1, Immutable.List([11])], 64 | ])), 65 | newest: {}, 66 | news: {}, 67 | show: {}, 68 | })); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /app/store/__tests__/reducers/user.spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | import * as ACTIONS from 'store/actionTypes'; 4 | import user from 'store/user'; 5 | 6 | describe('user reducer', () => { 7 | it('should return the initial state', () => { 8 | expect(user(undefined, {})).toBe(Immutable.Map()); 9 | }); 10 | 11 | it('should handle HACKER_USER_FETCH_SUCCESS', () => { 12 | expect(user(undefined, { 13 | type: ACTIONS.HACKER_USER_FETCH_SUCCESS, 14 | payload: { 15 | id: 'taehwanno', 16 | created_time: 1284124124, 17 | created: '3 years ago', 18 | karma: 0, 19 | avg: null, 20 | about: null, 21 | }, 22 | })).toEqual(Immutable.Map({ 23 | taehwanno: Immutable.Map({ 24 | id: 'taehwanno', 25 | createdTime: 1284124124, 26 | created: '3 years ago', 27 | karma: 0, 28 | avg: null, 29 | about: null, 30 | }), 31 | })); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /app/store/__tests__/selectors.spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import * as selectors from '../selectors'; 3 | 4 | describe('selectors', () => { 5 | const state = Immutable.fromJS({ 6 | byId: {}, 7 | comments: { 8 | byId: Immutable.Map(), 9 | posts: Immutable.Map(), 10 | }, 11 | currentPage: 1, 12 | isFetching: false, 13 | items: { 14 | ask: {}, 15 | jobs: {}, 16 | newest: {}, 17 | news: {}, 18 | show: {}, 19 | }, 20 | user: Immutable.Map(), 21 | }); 22 | 23 | it('should select byId', () => { 24 | expect(selectors.getById(state)).toEqual(state.get('byId')); 25 | }); 26 | 27 | it('should select currentPage', () => { 28 | expect(selectors.getCurrentPage(state)).toBe(state.get('currentPage')); 29 | }); 30 | 31 | it('should select isFetching', () => { 32 | expect(selectors.getIsFetching(state)).toBe(state.get('isFetching')); 33 | }); 34 | 35 | it('should select items', () => { 36 | expect(selectors.getItems(state)).toEqual(state.get('items')); 37 | }); 38 | 39 | it('should select props.type', () => { 40 | const props = { type: 'news' }; 41 | expect(selectors.getFeedType(null, props)).toBe(props.type); 42 | }); 43 | 44 | describe('getFeeds', () => { 45 | it('should return Immutable.List when specific type feeds not exist in current page', () => { 46 | expect(selectors.getFeeds(state, { type: 'news' })).toEqual(Immutable.List()); 47 | }); 48 | 49 | it('should return feeds when specific type feeds exist in current page', () => { 50 | const props = { type: 'ask' }; 51 | const currentState = Immutable.Map({ 52 | byId: Immutable.Map(new Map([ 53 | [1, { title: 'React with TypeScript', id: 1 }], 54 | [2, { title: 'TypeScript vs Flow', id: 2 }], 55 | ])), 56 | currentPage: 1, 57 | items: Immutable.Map({ 58 | ask: Immutable.Map(new Map([ 59 | [1, Immutable.List([1, 2])], 60 | ])), 61 | jobs: Immutable.Map(), 62 | newest: Immutable.Map(), 63 | news: Immutable.Map(), 64 | show: Immutable.Map(), 65 | }), 66 | }); 67 | expect(selectors.getFeeds(currentState, props)) 68 | .toEqual(currentState 69 | .getIn(['items', 'ask', currentState.get('currentPage')]) 70 | .map(id => currentState.getIn(['byId', id]))); 71 | }); 72 | }); 73 | 74 | it('should return feed count', () => { 75 | const props = { type: 'ask' }; 76 | const currentState = Immutable.Map({ 77 | byId: Immutable.Map(new Map([ 78 | [1, { title: 'React with TypeScript', id: 1 }], 79 | [2, { title: 'TypeScript vs Flow', id: 2 }], 80 | ])), 81 | currentPage: 1, 82 | items: Immutable.Map({ 83 | ask: Immutable.Map(new Map([ 84 | [1, Immutable.List([1, 2])], 85 | ])), 86 | jobs: Immutable.Map(), 87 | newest: Immutable.Map(), 88 | news: Immutable.Map(), 89 | show: Immutable.Map(), 90 | }), 91 | }); 92 | 93 | expect(selectors.getFeedCount(state, props)).toBe(0); 94 | expect(selectors.getFeedCount(currentState, props)).toBe(2); 95 | }); 96 | 97 | it('should select props.user', () => { 98 | const props = { user: 'taehwanno' }; 99 | expect(selectors.getUserId(null, props)).toBe(props.user); 100 | }); 101 | 102 | describe('getSpecificUser', () => { 103 | it('should return null when specific user information not exist', () => { 104 | expect(selectors.getSpecificUser(state, { user: 'taehwanno' })).toEqual(null); 105 | }); 106 | 107 | it('should return user information when specific user exist', () => { 108 | const props = { user: 'taehwanno' }; 109 | const currentState = Immutable.Map({ 110 | user: Immutable.Map({ 111 | taehwanno: { 112 | id: 'taehwanno', 113 | karma: 0, 114 | created: '3 years ago', 115 | }, 116 | }), 117 | }); 118 | expect(selectors.getSpecificUser(currentState, props)) 119 | .toEqual(currentState.getIn(['user', 'taehwanno'])); 120 | }); 121 | }); 122 | 123 | it('should select comments', () => { 124 | expect(selectors.getComments(state)).toEqual(state.get('comments')); 125 | }); 126 | 127 | it('should select props.commentId', () => { 128 | const props = { commentId: 1234 }; 129 | expect(selectors.getCommentId(null, props)).toBe(props.commentId); 130 | }); 131 | 132 | it('should select props.itemId', () => { 133 | const props = { itemId: 1234 }; 134 | expect(selectors.getItemId(null, props)).toBe(props.itemId); 135 | }); 136 | 137 | it('should select comments.byId', () => { 138 | expect(selectors.getCommentsById(state)).toEqual(state.getIn(['comments', 'byId'])); 139 | }); 140 | 141 | it('should select comments.posts', () => { 142 | expect(selectors.getCommentsPosts(state)).toEqual(state.getIn(['comments', 'posts'])); 143 | }); 144 | 145 | describe('getItem', () => { 146 | it('should return null when matched itemId\'s comment not exist', () => { 147 | expect(selectors.getItem(state, { itemId: 1234 })).toEqual(null); 148 | }); 149 | 150 | it('should return item when matched itemId\'s comment exist', () => { 151 | const comment = Immutable.Map(); 152 | expect(selectors.getItem(Immutable.Map({ 153 | comments: Immutable.Map({ 154 | posts: Immutable.Map(new Map([ 155 | [1234, comment], 156 | ])), 157 | }), 158 | }), { itemId: 1234 })).toEqual(comment); 159 | }); 160 | }); 161 | 162 | it('should select matched id\'s comments', () => { 163 | const comment = Immutable.Map({ comments: Immutable.List([1, 2]) }); 164 | const currentState = Immutable.Map({ 165 | comments: Immutable.Map({ 166 | byId: Immutable.Map(new Map([[4321, comment]])), 167 | }), 168 | }); 169 | expect(selectors.getChildrenComments(currentState, { commentId: 4321 })) 170 | .toEqual(comment.get('comments')); 171 | }); 172 | 173 | it('should return selector that select children comments', () => { 174 | const comment = Immutable.Map({ comments: Immutable.List([1, 2]) }); 175 | const currentState = Immutable.Map({ 176 | comments: Immutable.Map({ 177 | byId: Immutable.Map(new Map([[4321, comment]])), 178 | }), 179 | }); 180 | const getChildrenComments = selectors.makeGetChildrenComments(); 181 | expect(getChildrenComments(currentState, { commentId: 4321 })).toEqual(comment.get('comments')); 182 | }); 183 | 184 | it('should return selector that select comment contents', () => { 185 | const comment = Immutable.Map(); 186 | const currentState = Immutable.Map({ 187 | comments: Immutable.Map({ 188 | byId: Immutable.Map(new Map([[4321, comment]])), 189 | }), 190 | }); 191 | const getCommentContents = selectors.makeGetCommentContents(); 192 | expect(getCommentContents(currentState, { commentId: 4321 })).toEqual(comment); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /app/store/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const HACKER_COMMENTS_FETCH_REQUEST = 'HACKER_COMMENTS_FETCH_REQUEST'; 2 | export const HACKER_COMMENTS_FETCH_SUCCESS = 'HACKER_COMMENTS_FETCH_SUCCESS'; 3 | export const HACKER_COMMENTS_FETCH_FAILURE = 'HACKER_COMMENTS_FETCH_FAILURE'; 4 | export const HACKER_NEWS_FETCH_REQUEST = 'HACKER_NEWS_FETCH_REQUEST'; 5 | export const HACKER_NEWS_FETCH_SUCCESS = 'HACKER_NEWS_FETCH_SUCCESS'; 6 | export const HACKER_NEWS_FETCH_FAILURE = 'HACKER_NEWS_FETCH_FAILURE'; 7 | export const HACKER_USER_FETCH_REQUEST = 'HACKER_USER_FETCH_REQUEST'; 8 | export const HACKER_USER_FETCH_SUCCESS = 'HACKER_USER_FETCH_SUCCESS'; 9 | export const HACKER_USER_FETCH_FAILURE = 'HACKER_USER_FETCH_FAILURE'; 10 | -------------------------------------------------------------------------------- /app/store/actions.js: -------------------------------------------------------------------------------- 1 | import * as ACTIONS from './actionTypes'; 2 | import flattenComment from './flattenComments'; 3 | 4 | export function hackerCommentsFetchRequest(id) { 5 | return { 6 | type: ACTIONS.HACKER_COMMENTS_FETCH_REQUEST, 7 | payload: id, 8 | }; 9 | } 10 | 11 | export function hackerCommentsFetchSuccess(data) { 12 | return { 13 | type: ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS, 14 | payload: data, 15 | }; 16 | } 17 | 18 | export function hackerCommentsFetchFailure(error) { 19 | return { 20 | type: ACTIONS.HACKER_COMMENTS_FETCH_FAILURE, 21 | payload: error, 22 | error: true, 23 | }; 24 | } 25 | 26 | export function fetchHackerComments(id) { 27 | return function thunk(dispatch) { 28 | dispatch(hackerCommentsFetchRequest(id)); 29 | 30 | return fetch(`https://node-hnapi.herokuapp.com/item/${id}`) 31 | .then(response => 32 | response.json().then(data => 33 | dispatch(hackerCommentsFetchSuccess({ 34 | ...data, 35 | timeAgo: data.time_ago, 36 | comments: flattenComment(data).map(v => ({ ...v, timeAgo: v.time_ago })), 37 | })))) 38 | .catch(error => dispatch(hackerCommentsFetchFailure(error))); 39 | }; 40 | } 41 | 42 | export function hackerNewsFetchRequest(type, page) { 43 | return { 44 | type: ACTIONS.HACKER_NEWS_FETCH_REQUEST, 45 | payload: { 46 | type, 47 | page, 48 | }, 49 | }; 50 | } 51 | 52 | export function hackerNewsFetchSuccess(type, page, data) { 53 | return { 54 | type: ACTIONS.HACKER_NEWS_FETCH_SUCCESS, 55 | payload: { 56 | type, 57 | page, 58 | data, 59 | }, 60 | }; 61 | } 62 | 63 | export function hackerNewsFetchFailure(error) { 64 | return { 65 | type: ACTIONS.HACKER_NEWS_FETCH_FAILURE, 66 | payload: error, 67 | error: true, 68 | }; 69 | } 70 | 71 | export function fetchHackerNews(type, page) { 72 | return function thunk(dispatch) { 73 | dispatch(hackerNewsFetchRequest(type, page)); 74 | 75 | return fetch(`https://node-hnapi.herokuapp.com/${type}?page=${page}`) 76 | .then(response => 77 | response.json() 78 | .then(data => data.map((v) => { 79 | const { comments_count: commentsCount, time_ago: timeAgo, ...rest } = v; 80 | return { ...rest, commentsCount, timeAgo }; 81 | })) 82 | .then(data => dispatch(hackerNewsFetchSuccess(type, page, data)))) 83 | .catch(error => dispatch(hackerNewsFetchFailure(error))); 84 | }; 85 | } 86 | 87 | export function hackerUserFetchRequest(id) { 88 | return { 89 | type: ACTIONS.HACKER_USER_FETCH_REQUEST, 90 | payload: id, 91 | }; 92 | } 93 | 94 | export function hackerUserFetchSuccess(data) { 95 | return { 96 | type: ACTIONS.HACKER_USER_FETCH_SUCCESS, 97 | payload: data, 98 | }; 99 | } 100 | 101 | export function hackerUserFetchFailure(error) { 102 | return { 103 | type: ACTIONS.HACKER_USER_FETCH_FAILURE, 104 | payload: error, 105 | error: true, 106 | }; 107 | } 108 | 109 | export function fetchHackerUser(id) { 110 | return function thunk(dispatch) { 111 | dispatch(hackerUserFetchRequest(id)); 112 | 113 | return fetch(`https://node-hnapi.herokuapp.com/user/${id}`) 114 | .then(response => response.json().then(data => dispatch(hackerUserFetchSuccess(data)))) 115 | .catch(error => dispatch(hackerUserFetchFailure(error))); 116 | }; 117 | } 118 | -------------------------------------------------------------------------------- /app/store/byId/index.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import * as ACTIONS from '../actionTypes'; 3 | 4 | const initialState = Immutable.Map(); 5 | 6 | function byIdReducer(state = initialState, action) { 7 | let newState = state; 8 | 9 | switch (action.type) { 10 | case ACTIONS.HACKER_NEWS_FETCH_SUCCESS: 11 | action.payload.data.forEach((v) => { 12 | newState = newState.set(v.id, Immutable.Map(v)); 13 | }); 14 | return newState; 15 | default: 16 | return state; 17 | } 18 | } 19 | 20 | export default byIdReducer; 21 | -------------------------------------------------------------------------------- /app/store/comments/byId.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import * as ACTIONS from '../actionTypes'; 3 | 4 | const initialState = Immutable.Map(); 5 | 6 | function byId(state = initialState, action) { 7 | switch (action.type) { 8 | // eslint-disable-next-line no-case-declarations 9 | case ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS: 10 | let newState = state; 11 | action.payload.comments.forEach((comment) => { 12 | newState = newState.set(comment.id, Immutable.Map({ 13 | ...comment, 14 | comments: Immutable.List(comment.comments), 15 | })); 16 | }); 17 | 18 | return newState.set(action.payload.id, Immutable.Map({ 19 | ...action.payload, 20 | commentsCount: action.payload.comments_count, 21 | comments: Immutable.List(action.payload.comments 22 | .filter(v => v.parent === action.payload.id) 23 | .map(v => v.id)), 24 | })); 25 | default: 26 | return state; 27 | } 28 | } 29 | 30 | export default byId; 31 | -------------------------------------------------------------------------------- /app/store/comments/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux-immutable'; 2 | 3 | import byId from './byId'; 4 | import posts from './posts'; 5 | 6 | const commentsReducer = combineReducers({ 7 | byId, 8 | posts, 9 | }); 10 | 11 | export default commentsReducer; 12 | -------------------------------------------------------------------------------- /app/store/comments/posts.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import * as ACTIONS from '../actionTypes'; 3 | 4 | const initialState = Immutable.Map(); 5 | 6 | function postsReducer(state = initialState, action) { 7 | switch (action.type) { 8 | case ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS: 9 | return state.set(action.payload.id, Immutable.Map({ 10 | commentsCount: action.payload.comments_count, 11 | id: action.payload.id, 12 | title: action.payload.title, 13 | points: action.payload.points, 14 | user: action.payload.user, 15 | time: action.payload.time, 16 | timeAgo: action.payload.time_ago, 17 | type: action.payload.type, 18 | url: action.payload.url, 19 | domain: action.payload.domain, 20 | })); 21 | default: 22 | return state; 23 | } 24 | } 25 | 26 | export default postsReducer; 27 | -------------------------------------------------------------------------------- /app/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { createStore, applyMiddleware, compose } from 'redux'; 3 | import thunk from 'redux-thunk'; 4 | 5 | import rootReducer from './rootReducer'; 6 | 7 | const configureStore = ({ routerMiddleware = null, preloadedState = Immutable.Map() } = {}) => { 8 | const middlewares = [thunk]; 9 | 10 | if (routerMiddleware) { 11 | middlewares.push(routerMiddleware); 12 | } 13 | 14 | let enhancer = applyMiddleware(...middlewares); 15 | 16 | if (__DEV__ && !__SERVER__) { 17 | const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 18 | enhancer = composeEnhancer(applyMiddleware(...middlewares)); 19 | } 20 | 21 | const store = createStore(rootReducer, preloadedState, enhancer); 22 | 23 | if (__DEV__) { 24 | if (module.hot) { 25 | module.hot.accept('./rootReducer', () => { 26 | // eslint-disable-next-line global-require 27 | const nextRootReducer = require('./rootReducer').default; 28 | store.replaceReducer(nextRootReducer); 29 | }); 30 | } 31 | } 32 | 33 | return store; 34 | }; 35 | 36 | export default configureStore; 37 | -------------------------------------------------------------------------------- /app/store/currentPage/index.js: -------------------------------------------------------------------------------- 1 | import * as ACTIONS from '../actionTypes'; 2 | 3 | const initialState = 1; 4 | 5 | function currentPageReducer(state = initialState, action) { 6 | switch (action.type) { 7 | case ACTIONS.HACKER_NEWS_FETCH_REQUEST: 8 | return action.payload.page; 9 | default: 10 | return state; 11 | } 12 | } 13 | 14 | export default currentPageReducer; 15 | -------------------------------------------------------------------------------- /app/store/flattenComments.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Flatten nested comments 3 | * 4 | * @param {Object} data 5 | * @param {Number} data.id - Hacker news item id 6 | * @param {Array} data.comments - comments array 7 | * @return {Array} flattened comments 8 | */ 9 | function flattenComments(data) { 10 | let results = []; 11 | let iterables = [ 12 | { parent: data.id, comments: data.comments }, 13 | ]; 14 | 15 | while (iterables.length !== 0) { 16 | results = iterables.map(iterable => 17 | iterable.comments.map(comment => ({ 18 | ...comment, 19 | comments: comment.comments.map(v => v.id), 20 | parent: iterable.parent, 21 | }))).reduce((prev, next) => next.concat(prev), results); 22 | 23 | iterables = iterables.map(iterable => 24 | iterable.comments.map(children => 25 | ({ parent: children.id, comments: children.comments }))) 26 | .reduce((prev, next) => next.concat(prev), []); 27 | } 28 | 29 | return results; 30 | } 31 | 32 | export default flattenComments; 33 | -------------------------------------------------------------------------------- /app/store/history.js: -------------------------------------------------------------------------------- 1 | import createHistory from 'history/createBrowserHistory'; 2 | import { routerMiddleware } from 'react-router-redux'; 3 | 4 | export const history = createHistory(); 5 | export const middleware = routerMiddleware(history); 6 | -------------------------------------------------------------------------------- /app/store/isFetching/index.js: -------------------------------------------------------------------------------- 1 | import * as ACTIONS from '../actionTypes'; 2 | 3 | function itemsReducer(state = false, action) { 4 | switch (action.type) { 5 | case ACTIONS.HACKER_COMMENTS_FETCH_REQUEST: 6 | case ACTIONS.HACKER_NEWS_FETCH_REQUEST: 7 | case ACTIONS.HACKER_USER_FETCH_REQUEST: 8 | return true; 9 | case ACTIONS.HACKER_COMMENTS_FETCH_SUCCESS: 10 | case ACTIONS.HACKER_COMMENTS_FETCH_FAILURE: 11 | case ACTIONS.HACKER_NEWS_FETCH_SUCCESS: 12 | case ACTIONS.HACKER_NEWS_FETCH_FAILURE: 13 | case ACTIONS.HACKER_USER_FETCH_SUCCESS: 14 | case ACTIONS.HACKER_USER_FETCH_FAILURE: 15 | return false; 16 | default: 17 | return state; 18 | } 19 | } 20 | 21 | export default itemsReducer; 22 | -------------------------------------------------------------------------------- /app/store/items/index.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import * as ACTIONS from '../actionTypes'; 3 | 4 | const initialState = Immutable.fromJS({ 5 | ask: {}, 6 | jobs: {}, 7 | newest: {}, 8 | news: {}, 9 | show: {}, 10 | }); 11 | 12 | function itemsReducer(state = initialState, action) { 13 | switch (action.type) { 14 | case ACTIONS.HACKER_NEWS_FETCH_SUCCESS: 15 | return state.setIn( 16 | [action.payload.type, action.payload.page], 17 | Immutable.List(action.payload.data.map(v => v.id)), 18 | ); 19 | default: 20 | return state; 21 | } 22 | } 23 | 24 | export default itemsReducer; 25 | -------------------------------------------------------------------------------- /app/store/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux-immutable'; 2 | import { routerReducer } from 'react-router-redux'; 3 | 4 | import byId from './byId'; 5 | import comments from './comments'; 6 | import currentPage from './currentPage'; 7 | import isFetching from './isFetching'; 8 | import items from './items'; 9 | import user from './user'; 10 | 11 | const rootReducer = combineReducers({ 12 | byId, 13 | comments, 14 | currentPage, 15 | isFetching, 16 | items, 17 | user, 18 | router: routerReducer, 19 | }); 20 | 21 | export default rootReducer; 22 | -------------------------------------------------------------------------------- /app/store/selectors.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { createSelector } from 'reselect'; 3 | 4 | export const getById = state => state.get('byId'); 5 | export const getCurrentPage = state => state.get('currentPage'); 6 | export const getIsFetching = state => state.get('isFetching'); 7 | export const getItems = state => state.get('items'); 8 | export const getUser = state => state.get('user'); 9 | 10 | export const getFeedType = (_, props) => props.type; 11 | export const getFeeds = createSelector( 12 | [getById, getCurrentPage, getItems, getFeedType], 13 | (byId, currentPage, items, type) => { 14 | const feeds = items.getIn([type, currentPage]); 15 | 16 | if (!feeds) return Immutable.List(); 17 | 18 | return feeds.map(id => byId.get(id)); 19 | }, 20 | ); 21 | export const getFeedCount = createSelector( 22 | getFeeds, 23 | feeds => feeds.size, 24 | ); 25 | 26 | export const getUserId = (_, props) => props.user; 27 | export const getSpecificUser = createSelector( 28 | [getUser, getUserId], 29 | (user, id) => { 30 | const information = user.get(id); 31 | 32 | if (!information) return null; 33 | return information; 34 | }, 35 | ); 36 | 37 | export const getComments = state => state.get('comments'); 38 | export const getCommentId = (_, props) => props.commentId; 39 | export const getItemId = (_, props) => props.itemId; 40 | export const getCommentsById = createSelector( 41 | getComments, 42 | comments => comments.get('byId'), 43 | ); 44 | export const getCommentsPosts = createSelector( 45 | getComments, 46 | comments => comments.get('posts'), 47 | ); 48 | 49 | export const getItem = createSelector( 50 | getCommentsPosts, 51 | getItemId, 52 | (commentsPosts, itemId) => { 53 | const item = commentsPosts.get(itemId); 54 | 55 | if (!item) return null; 56 | return item; 57 | }, 58 | ); 59 | export const getChildrenComments = createSelector( 60 | getCommentsById, 61 | getCommentId, 62 | (commentsIds, commentId) => commentsIds.getIn([commentId, 'comments']), 63 | ); 64 | export const makeGetChildrenComments = () => createSelector( 65 | getCommentsById, 66 | getCommentId, 67 | (commentsIds, commentId) => commentsIds.getIn([commentId, 'comments']), 68 | ); 69 | export const makeGetCommentContents = () => createSelector( 70 | getCommentsById, 71 | getCommentId, 72 | (commentsIds, commentId) => commentsIds.get(commentId), 73 | ); 74 | -------------------------------------------------------------------------------- /app/store/user/index.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import * as ACTIONS from '../actionTypes'; 3 | 4 | const initialState = Immutable.Map(); 5 | 6 | function userReducer(state = initialState, action) { 7 | switch (action.type) { 8 | case ACTIONS.HACKER_USER_FETCH_SUCCESS: 9 | return state.set(action.payload.id, Immutable.Map({ 10 | id: action.payload.id, 11 | createdTime: action.payload.created_time, 12 | created: action.payload.created, 13 | karma: action.payload.karma, 14 | avg: action.payload.avg, 15 | about: action.payload.about, 16 | })); 17 | default: 18 | return state; 19 | } 20 | } 21 | 22 | export default userReducer; 23 | -------------------------------------------------------------------------------- /devServer.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const webpackDevMiddleware = require('webpack-dev-middleware'); 4 | const webpackHotMiddleware = require('webpack-hot-middleware'); 5 | const MemoryFileSystem = require('memory-fs'); 6 | const fetch = require('node-fetch'); 7 | const express = require('express'); 8 | const ejs = require('ejs'); 9 | const serialize = require('serialize-javascript'); 10 | const transit = require('transit-immutable-js'); 11 | const requireFromString = require('require-from-string'); 12 | 13 | const paths = require('./paths.js'); 14 | const app = express(); 15 | const clientConfig = require('./webpack.dev.config')(); 16 | const serverConfig = require('./webpack.server.config'); 17 | const clientCompiler = webpack(clientConfig); 18 | const serverCompiler = webpack(serverConfig); 19 | 20 | global.fetch = fetch; 21 | 22 | let clientReady; 23 | let serverReady; 24 | const serverPromise = new Promise((resolve) => { serverReady = resolve; }); 25 | const clientPromise = new Promise((resolve) => { clientReady = resolve; }); 26 | const allEnvironmentReady = Promise 27 | .all([clientPromise, serverPromise]) 28 | .then(() => console.info('All bundling process complete')); 29 | 30 | let render; 31 | let indexView; 32 | 33 | const clientMfs = new MemoryFileSystem(); 34 | clientCompiler.outputFileSystem = clientMfs; 35 | clientCompiler.plugin('done', (stats) => { 36 | stats = stats.toJson(); 37 | if (stats.errors.length) return; 38 | 39 | indexView = clientMfs.readFileSync(path.join(paths.functions, 'views/index.ejs'), 'utf-8'); 40 | clientReady(); 41 | }); 42 | 43 | const serverMfs = new MemoryFileSystem(); 44 | serverCompiler.outputFileSystem = serverMfs; 45 | serverCompiler.watch({}, (err, stats) => { 46 | if (err) throw err; 47 | stats = stats.toJson(); 48 | if (stats.errors.length) return; 49 | 50 | const bundle = serverMfs.readFileSync(path.join(serverConfig.output.path, 'server.bundle.js'), 'utf-8'); 51 | render = requireFromString(bundle).default; 52 | serverReady(); 53 | }); 54 | 55 | app.use(webpackDevMiddleware(clientCompiler, { noInfo: true, publicPath: clientConfig.output.publicPath })); 56 | app.use(webpackHotMiddleware(clientCompiler, { heartbeat: 5000 })); 57 | 58 | app.get('/', (req, res) => { 59 | res.redirect(301, '/news/1'); 60 | }); 61 | 62 | app.get('*', (req, res) => { 63 | allEnvironmentReady.then(() => { 64 | render(req.url) 65 | .then(({ markup, state }) => 66 | res.send(ejs.render(indexView, { markup, state: serialize(transit.toJSON(state)) }))) 67 | }); 68 | }); 69 | 70 | const port = process.env.PORT || 8080; 71 | app.listen(port, () => { 72 | console.info(`server started at localhost:${port}`); 73 | }); 74 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "redirects": [ 10 | { 11 | "source": "/", 12 | "destination" : "/news/1", 13 | "type" : 301 14 | } 15 | ], 16 | "rewrites": [ 17 | { 18 | "source": "/@(news|newest|show|ask|jobs|item|user)/*", 19 | "function": "app" 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const functions = require('firebase-functions'); 3 | const fetch = require('node-fetch'); 4 | const express = require('express'); 5 | const serialize = require('serialize-javascript'); 6 | const transit = require('transit-immutable-js'); 7 | 8 | const app = express(); 9 | const render = require('./server.bundle').default; 10 | 11 | global.fetch = fetch; 12 | 13 | app.set('view engine', 'ejs'); 14 | app.set('views', __dirname + '/views'); 15 | 16 | app.get('*', (req, res) => { 17 | render(req.url) 18 | .then(({ markup, state }) => 19 | res.render('index', { markup, state: serialize(transit.toJSON(state)) })); 20 | }); 21 | 22 | exports.app = functions.https.onRequest(app); 23 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "dependencies": { 5 | "ejs": "^2.5.7", 6 | "express": "^4.16.1", 7 | "firebase-admin": "~5.4.0", 8 | "firebase-functions": "^0.7.0", 9 | "immutable": "^3.8.2", 10 | "node-fetch": "^1.7.3", 11 | "serialize-javascript": "^1.4.0", 12 | "transit-immutable-js": "^0.7.0", 13 | "transit-js": "^0.8.846" 14 | }, 15 | "private": true 16 | } 17 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectCoverageFrom": [ 3 | "app/**/*.{js,jsx}", 4 | "!**/*.stories.{js,jsx}" 5 | ], 6 | "globals": { 7 | "__DEV__": false, 8 | "__PROD__": false, 9 | "__SERVER__": false 10 | }, 11 | "moduleFileExtensions": ["js", "jsx"], 12 | "moduleNameMapper": { 13 | "\\.(css|scss)$": "/test/__mocks__/styleMock.js", 14 | "\\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|cur)$": "/test/__mocks__/fileMock.js", 15 | "^components(.*)$": "/app/components$1", 16 | "^containers(.*)$": "/app/containers$1", 17 | "^routes(.*)$": "/app/routes$1", 18 | "^pages(.*)$": "/app/pages$1", 19 | "^store(.*)$": "/app/store$1" 20 | }, 21 | "roots": [ 22 | "/app", 23 | "/test" 24 | ], 25 | "setupFiles": [ 26 | "/test/shim.js", 27 | "/test/setup.js" 28 | ], 29 | "snapshotSerializers": ["enzyme-to-json/serializer"], 30 | "transformIgnorePatterns": [ 31 | "node_modules/?!(autotrack|dom-utils)", 32 | "node_modules/?!(react-router/es)" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hnpwa-react", 3 | "private": true, 4 | "version": "1.0.4", 5 | "description": "HNPWA with React", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "cross-env NODE_ENV=development node ./devServer.js", 9 | "start:firebase": "firebase serve --only hosting,functions", 10 | "analyze": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js --env.prod --profile --json > stats.json && yarn analyze:cache", 11 | "analyze:cache": "webpack-bundle-analyzer stats.json bundleDir=build", 12 | "build": "yarn build:client && yarn build:server", 13 | "build:client": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js --progress --env.prod", 14 | "build:server": "cross-env NODE_ENV=production webpack --config webpack.server.config.js --progress", 15 | "deploy": "firebase deploy --project hnpwa-react --token=$FIREBASE_DEPLOY_TOKEN", 16 | "deploy:stage": "firebase deploy --project hnpwa-react-stage --token=$FIREBASE_DEPLOY_TOKEN", 17 | "lint": "npm-run-all --parallel lint:*", 18 | "lint:js": "eslint app test --ext .js,.jsx", 19 | "lint:scss": "bundle exec scss-lint app/", 20 | "ncu": "ncu", 21 | "precommit": "lint-staged", 22 | "prepush": "yarn lint", 23 | "test": "cross-env NODE_ENV=test jest --config jest.config.json", 24 | "test:coverage": "yarn test -- --coverage", 25 | "test:watch": "yarn test -- --watch", 26 | "coverage": "open ./coverage/lcov-report/index.html", 27 | "storybook": "start-storybook -p 9001 -c .storybook", 28 | "gh-pages": "yarn gh-pages:clean && yarn gh-pages:build && yarn gh-pages:publish", 29 | "gh-pages:clean": "rimraf gh-pages", 30 | "gh-pages:build": "yarn build-storybook -o gh-pages", 31 | "gh-pages:publish": "git-directory-deploy --directory gh-pages --message '[skip ci]'" 32 | }, 33 | "lint-staged": { 34 | "*.{js,jsx}": "eslint", 35 | "*.{css,scss}": "bundle exec scss-lint" 36 | }, 37 | "dependencies": { 38 | "autotrack": "^2.4.1", 39 | "babel-polyfill": "^6.26.0", 40 | "classnames": "^2.2.5", 41 | "history": "^4.7.2", 42 | "immutable": "^3.8.2", 43 | "lodash": "^4.17.4", 44 | "normalize.css": "^7.0.0", 45 | "prop-types": "^15.6.0", 46 | "react": "^16.2.0", 47 | "react-dom": "^16.2.0", 48 | "react-hot-loader": "^3.1.3", 49 | "react-redux": "^5.0.6", 50 | "react-router-dom": "^4.2.2", 51 | "react-router-redux": "^5.0.0-alpha.6", 52 | "react-router-server": "^4.2.2", 53 | "redux": "^3.7.2", 54 | "redux-immutable": "^4.0.0", 55 | "redux-thunk": "^2.2.0", 56 | "reselect": "^3.0.1", 57 | "serialize-javascript": "^1.4.0", 58 | "transit-immutable-js": "^0.7.0", 59 | "transit-js": "^0.8.846", 60 | "uuid": "^3.1.0", 61 | "whatwg-fetch": "^2.0.3" 62 | }, 63 | "devDependencies": { 64 | "@storybook/addon-actions": "^3.2.17", 65 | "@storybook/addon-info": "^3.2.17", 66 | "@storybook/addon-knobs": "^3.2.17", 67 | "@storybook/addon-links": "^3.2.17", 68 | "@storybook/addon-options": "^3.2.17", 69 | "@storybook/react": "^3.2.17", 70 | "babel-core": "^6.26.0", 71 | "babel-eslint": "^8.0.3", 72 | "babel-jest": "^21.2.0", 73 | "babel-loader": "^7.1.2", 74 | "babel-plugin-add-module-exports": "^0.2.1", 75 | "babel-plugin-dynamic-import-node": "^1.2.0", 76 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 77 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 78 | "babel-plugin-transform-imports": "^1.4.1", 79 | "babel-preset-airbnb": "^2.4.0", 80 | "babel-preset-es2015": "^6.24.1", 81 | "babel-preset-react": "^6.24.1", 82 | "babel-preset-stage-2": "^6.24.1", 83 | "case-sensitive-paths-webpack-plugin": "^2.1.1", 84 | "clean-webpack-plugin": "^0.1.17", 85 | "compression-webpack-plugin": "^1.0.1", 86 | "copy-webpack-plugin": "^4.2.3", 87 | "cross-env": "^5.1.1", 88 | "css-loader": "^0.28.7", 89 | "csso-webpack-plugin": "^1.0.0-beta.10", 90 | "enzyme": "^3.2.0", 91 | "enzyme-adapter-react-16": "^1.1.0", 92 | "enzyme-to-json": "^3.2.2", 93 | "eslint": "^4.13.1", 94 | "eslint-config-airbnb": "^16.1.0", 95 | "eslint-import-resolver-webpack": "^0.8.3", 96 | "eslint-loader": "^1.9.0", 97 | "eslint-plugin-import": "^2.8.0", 98 | "eslint-plugin-jest": "^21.4.2", 99 | "eslint-plugin-jsx-a11y": "^6.0.3", 100 | "eslint-plugin-react": "^7.5.1", 101 | "extract-text-webpack-plugin": "^3.0.2", 102 | "file-loader": "^1.1.5", 103 | "firebase-tools": "^3.16.0", 104 | "git-directory-deploy": "^1.5.1", 105 | "html-webpack-plugin": "^2.30.1", 106 | "husky": "^0.14.3", 107 | "ignore-loader": "^0.1.2", 108 | "jest": "^21.2.1", 109 | "lint-staged": "^6.0.0", 110 | "memory-fs": "^0.4.1", 111 | "nock": "^9.1.4", 112 | "node-fetch": "^1.7.3", 113 | "node-sass": "^4.7.2", 114 | "npm-check-updates": "^2.13.0", 115 | "npm-run-all": "^4.1.2", 116 | "postcss-loader": "^2.0.9", 117 | "preload-webpack-plugin": "^2.0.0", 118 | "react-addons-test-utils": "^15.6.2", 119 | "react-test-renderer": "^16.2.0", 120 | "redux-mock-store": "^1.3.0", 121 | "require-from-string": "^2.0.1", 122 | "resolve-url-loader": "^2.2.1", 123 | "rimraf": "^2.6.2", 124 | "sass-loader": "^6.0.6", 125 | "sass-resources-loader": "^1.3.1", 126 | "style-loader": "^0.19.0", 127 | "webpack": "^3.10.0", 128 | "webpack-bundle-analyzer": "^2.9.1", 129 | "webpack-dev-middleware": "^1.12.2", 130 | "webpack-dev-server": "^2.9.7", 131 | "webpack-hot-middleware": "^2.21.0", 132 | "webpack-merge": "^4.1.1", 133 | "workbox-google-analytics": "^2.1.1", 134 | "workbox-webpack-plugin": "^2.1.2" 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /paths.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const root = __dirname; 4 | const app = path.resolve(root, 'app'); 5 | const build = path.resolve(root, 'build'); 6 | const functions = path.resolve(root, 'functions'); 7 | const publicDir = path.resolve(root, 'public'); 8 | const server = path.resolve(root, 'server'); 9 | 10 | const assets = path.resolve(app, 'assets'); 11 | const components = path.resolve(app, 'components'); 12 | const containers = path.resolve(app, 'containers'); 13 | const pages = path.resolve(app, 'pages'); 14 | const store = path.resolve(app, 'store'); 15 | const test = path.resolve(app, 'test'); 16 | 17 | module.exports = { 18 | root, 19 | app, 20 | build, 21 | functions, 22 | public: publicDir, 23 | server, 24 | 25 | assets, 26 | components, 27 | containers, 28 | pages, 29 | store, 30 | test, 31 | }; 32 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer'), 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwanno/hnpwa-react/264d72bdea97ccee76d8f370b8a89f099d246603/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwanno/hnpwa-react/264d72bdea97ccee76d8f370b8a89f099d246603/public/icon-144x144.png -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwanno/hnpwa-react/264d72bdea97ccee76d8f370b8a89f099d246603/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwanno/hnpwa-react/264d72bdea97ccee76d8f370b8a89f099d246603/public/icon-512x512.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwanno/hnpwa-react/264d72bdea97ccee76d8f370b8a89f099d246603/public/logo.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "HNPWA with React", 3 | "short_name": "HNPWA", 4 | "icons": [ 5 | { 6 | "src": "icon-144x144.png", 7 | "sizes": "144x144", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "icon-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "icon-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "start_url": "/news/1?utm_source=homescreen", 22 | "display": "standalone", 23 | "orientation": "portrait", 24 | "background_color": "#000000", 25 | "theme_color": "#f4751e" 26 | } 27 | -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwanno/hnpwa-react/264d72bdea97ccee76d8f370b8a89f099d246603/public/og-image.png -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | importScripts('https://unpkg.com/workbox-google-analytics@2.1.0/build/importScripts/workbox-google-analytics.prod.v2.1.0.js'); 2 | importScripts('https://unpkg.com/workbox-sw@2.1.0/build/importScripts/workbox-sw.prod.v2.1.0.js'); 3 | 4 | /* global workbox, WorkboxSW */ 5 | 6 | workbox.googleAnalytics.initialize({ 7 | parameterOverrides: { 8 | cd10: 'offline' 9 | }, 10 | hitFilter: (searchParams) => { 11 | const qt = searchParams.get('qt'); 12 | searchParams.set('cm7', qt); 13 | } 14 | }); 15 | 16 | const workboxSW = new WorkboxSW({ clientsClaim: true }); 17 | 18 | workboxSW.precache([]); 19 | 20 | workboxSW.router.registerRoute( 21 | 'https://node-hnapi.herokuapp.com/(.*)', 22 | workboxSW.strategies.networkFirst({ networkTimeoutSeconds: 3 }) 23 | ); 24 | 25 | workboxSW.router.setDefaultHandler({ 26 | handler: workboxSW.strategies.staleWhileRevalidate() 27 | }); 28 | -------------------------------------------------------------------------------- /test/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /test/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /test/shim.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | global.fetch = fetch; 4 | 5 | global.requestAnimationFrame = function raf(callback) { 6 | setTimeout(callback, 0); 7 | }; 8 | -------------------------------------------------------------------------------- /webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { 4 | optimize, 5 | DefinePlugin, 6 | NamedModulesPlugin, 7 | NoEmitOnErrorsPlugin, 8 | } = require('webpack'); 9 | const { 10 | CommonsChunkPlugin, 11 | } = optimize; 12 | 13 | const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); 14 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 15 | const PreloadWebpackPlugin = require('preload-webpack-plugin'); 16 | 17 | const paths = require('./paths'); 18 | 19 | module.exports = { 20 | entry: { 21 | vendor: [ 22 | 'classnames', 23 | 'immutable', 24 | 'react', 25 | 'prop-types', 26 | 'react-dom', 27 | 'react-redux', 28 | 'redux', 29 | 'redux-immutable', 30 | 'redux-thunk', 31 | 'reselect', 32 | 'transit-immutable-js', 33 | 'whatwg-fetch', 34 | ], 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.jsx?$/, 40 | include: [ 41 | paths.app, 42 | ], 43 | exclude: /node_modules/, 44 | enforce: 'pre', 45 | loader: 'eslint-loader', 46 | }, 47 | { 48 | test: /\.css$/, 49 | loader: 'css-loader', 50 | }, 51 | ], 52 | }, 53 | plugins: [ 54 | new CaseSensitivePathsPlugin(), 55 | new HtmlWebpackPlugin({ 56 | template: path.join(paths.app, 'index.ejs'), 57 | markup: '
', 58 | inject: false, 59 | }), 60 | new HtmlWebpackPlugin({ 61 | template: path.join(paths.app, 'index.ejs'), 62 | filename: path.resolve(paths.functions, 'views/index.ejs'), 63 | markup: ` 64 |
<%- markup %>
65 | <% if (state) { %> 66 | 67 | <% } %> 68 | `, 69 | inject: false, 70 | }), 71 | new PreloadWebpackPlugin({ 72 | rel: 'preload', 73 | fileBlacklist: [/\.map/, /\.hot-update\.js$/], 74 | include: ['app', 'runtime', 'vendor'], 75 | }), 76 | new DefinePlugin({ 77 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 78 | '__DEV__': process.env.NODE_ENV === 'development', 79 | '__PROD__': process.env.NODE_ENV === 'production', 80 | '__SERVER__': false, 81 | }), 82 | new CommonsChunkPlugin({ 83 | name: 'vendor', 84 | minChunks: module => module.resource && (/node_modules/).test(module.resource), 85 | }), 86 | new CommonsChunkPlugin({ 87 | chunks: ['analytics'], 88 | async: 'async-analytics', 89 | minChunks: module => module.resource && (/node_modules/).test(module.resource), 90 | }), 91 | new CommonsChunkPlugin({ name: 'runtime' }), 92 | new NamedModulesPlugin(), 93 | new NoEmitOnErrorsPlugin(), 94 | ], 95 | resolve: { 96 | alias: { 97 | assets: paths.assets, 98 | components: paths.components, 99 | containers: paths.containers, 100 | pages: paths.pages, 101 | store: paths.store, 102 | }, 103 | extensions: ['.js', '.jsx'], 104 | }, 105 | stats: { 106 | colors: true, 107 | reasons: true, 108 | }, 109 | }; 110 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { optimize, HotModuleReplacementPlugin } = require('webpack'); 4 | const { 5 | CommonsChunkPlugin, 6 | } = optimize; 7 | 8 | const webpackMerge = require('webpack-merge'); 9 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 10 | 11 | const paths = require('./paths'); 12 | const webpackBaseConfig = require('./webpack.base.config'); 13 | 14 | module.exports = () => { 15 | return webpackMerge(webpackBaseConfig, { 16 | cache: true, 17 | entry: { 18 | app: [ 19 | 'webpack-hot-middleware/client', 20 | 'react-hot-loader/patch', 21 | path.resolve(paths.app, 'client.jsx'), 22 | ], 23 | }, 24 | output: { 25 | filename: '[name].bundle.js', 26 | chunkFilename: '[name].bundle.js', 27 | path: paths.build, 28 | pathinfo: true, 29 | publicPath: '/', 30 | }, 31 | devServer: { 32 | contentBase: paths.public, 33 | historyApiFallback: true, 34 | hot: true, 35 | port: 8080, 36 | }, 37 | devtool: 'eval-source-map', 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.jsx?$/, 42 | loader: 'babel-loader', 43 | options: { 44 | cacheDirectory: true, 45 | }, 46 | }, 47 | { 48 | test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|cur)$/, 49 | loader: 'file-loader', 50 | options: { 51 | name: '[path][name].[ext]', 52 | }, 53 | }, 54 | { 55 | test: /\.scss$/, 56 | use: [ 57 | 'style-loader', 58 | { 59 | loader: 'css-loader', 60 | options: { 61 | sourceMap: true, 62 | }, 63 | }, 64 | { 65 | loader: 'postcss-loader', 66 | options: { 67 | sourceMap: true, 68 | }, 69 | }, 70 | 'resolve-url-loader', 71 | { 72 | loader: 'sass-loader', 73 | options: { 74 | sourceMap: true, 75 | }, 76 | }, 77 | { 78 | loader: 'sass-resources-loader', 79 | options: { 80 | resources: [ 81 | './app/scss/base/**/_*.scss', 82 | ], 83 | }, 84 | } 85 | ], 86 | }, 87 | ], 88 | }, 89 | plugins: [ 90 | new HotModuleReplacementPlugin(), 91 | new CopyWebpackPlugin([ 92 | { 93 | context: paths.public, 94 | from: '*.*', 95 | }, 96 | ]), 97 | ], 98 | }); 99 | }; 100 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { optimize, LoaderOptionsPlugin } = require('webpack'); 4 | const { 5 | ModuleConcatenationPlugin, 6 | } = optimize; 7 | 8 | const webpackMerge = require('webpack-merge'); 9 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 10 | const CompressionPlugin = require('compression-webpack-plugin'); 11 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 12 | const CSSOWebpackPlugin = require('csso-webpack-plugin').default; 13 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 14 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 15 | const WorkboxWebpackPlugin = require('workbox-webpack-plugin'); 16 | 17 | const paths = require('./paths'); 18 | const webpackBaseConfig = require('./webpack.base.config'); 19 | 20 | module.exports = webpackMerge(webpackBaseConfig, { 21 | entry: { 22 | app: path.resolve(paths.app, 'client.jsx'), 23 | }, 24 | output: { 25 | filename: '[name].bundle-[chunkhash].js', 26 | chunkFilename: '[name].bundle-[chunkhash].js', 27 | path: paths.build, 28 | publicPath: '/', 29 | sourceMapFilename: '[name].bundle-[chunkhash].map', 30 | }, 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.jsx?$/, 35 | exclude: /node_modules\/(?!(autotrack|dom-utils))/, 36 | loader: 'babel-loader' 37 | }, 38 | { 39 | test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|cur)$/, 40 | loader: 'file-loader', 41 | options: { 42 | name: 'assets/[hash].[ext]', 43 | }, 44 | }, 45 | { 46 | test: /\.scss$/, 47 | loader: ExtractTextPlugin.extract({ 48 | fallback: 'style-loader', 49 | use: [ 50 | 'css-loader', 51 | { 52 | loader: 'postcss-loader', 53 | options: { 54 | sourceMap: true, 55 | }, 56 | }, 57 | 'resolve-url-loader', 58 | { 59 | loader: 'sass-loader', 60 | options: { 61 | sourceMap: true, 62 | }, 63 | }, 64 | { 65 | loader: 'sass-resources-loader', 66 | options: { 67 | resources: [ 68 | './app/scss/base/**/_*.scss', 69 | ], 70 | } 71 | } 72 | ], 73 | }), 74 | } 75 | ], 76 | }, 77 | plugins: [ 78 | new CleanWebpackPlugin(['build/*'], { 79 | dry: false, 80 | verbose: true, 81 | }), 82 | new CopyWebpackPlugin([ 83 | { 84 | context: paths.public, 85 | from: '*.*', 86 | ignore: ['service-worker.js'], 87 | }, 88 | ]), 89 | new ExtractTextPlugin({ 90 | allChunks: true, 91 | filename: 'app.bundle-[chunkhash].css', 92 | }), 93 | new CSSOWebpackPlugin(), 94 | new LoaderOptionsPlugin({ minimize: true, debug: false }), 95 | new ModuleConcatenationPlugin(), 96 | new UglifyJsPlugin(), 97 | new CompressionPlugin({ 98 | asset: '[path].gz[query]', 99 | algorithm: 'gzip', 100 | test: /\.(js|css)$/, 101 | minRatio: 0.8, 102 | }), 103 | new WorkboxWebpackPlugin({ 104 | swSrc: 'public/service-worker.js', 105 | swDest: 'build/service-worker.js', 106 | globPatterns: ['**/*.{html,js,css,png,jpg,json}'], 107 | globIgnores: ['**/service-worker.js', 'index.html', 'workbox-sw.js'], 108 | }), 109 | ], 110 | }); 111 | -------------------------------------------------------------------------------- /webpack.server.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { 3 | DefinePlugin, 4 | NamedModulesPlugin, 5 | NoEmitOnErrorsPlugin, 6 | } = require('webpack'); 7 | const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); 8 | 9 | const paths = require('./paths'); 10 | 11 | const isProd = process.env.NODE_ENV === 'production'; 12 | 13 | module.exports = { 14 | entry: path.resolve(paths.app, 'server.jsx'), 15 | target: 'node', 16 | output: { 17 | path: paths.functions, 18 | publicPath: '/', 19 | filename: 'server.bundle.js', 20 | libraryTarget: 'commonjs2', 21 | }, 22 | resolve: { 23 | alias: { 24 | assets: paths.assets, 25 | components: paths.components, 26 | containers: paths.containers, 27 | pages: paths.pages, 28 | store: paths.store, 29 | }, 30 | extensions: ['.js', '.jsx'], 31 | }, 32 | module: { 33 | strictExportPresence: true, 34 | rules: [ 35 | { 36 | exclude: /\.(jsx?|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|cur)$/, 37 | loader: 'ignore-loader', 38 | }, 39 | { 40 | test: /\.jsx?$/, 41 | include: paths.app, 42 | loader: 'babel-loader', 43 | options: { 44 | cacheDirectory: true, 45 | }, 46 | }, 47 | { 48 | test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|cur)$/, 49 | loader: 'file-loader', 50 | options: { 51 | emitFile: false, 52 | name: isProd ? 'assets/[hash].[ext]' : '[path][name].[ext]', 53 | }, 54 | }, 55 | ] 56 | }, 57 | plugins: [ 58 | new CaseSensitivePathsPlugin(), 59 | new DefinePlugin({ 60 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 61 | '__DEV__': process.env.NODE_ENV === 'development', 62 | '__PROD__': process.env.NODE_ENV === 'production', 63 | '__SERVER__': true, 64 | }), 65 | new NamedModulesPlugin(), 66 | new NoEmitOnErrorsPlugin(), 67 | ] 68 | }; 69 | --------------------------------------------------------------------------------