├── .env ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── data-for-testing.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── hacktoberfest-react.png ├── hacktoberfest-screenshot.png ├── index.html └── manifest.json ├── src ├── components │ ├── App │ │ ├── App.js │ │ ├── App.test.js │ │ ├── __snapshots__ │ │ │ └── App.test.js.snap │ │ ├── index.js │ │ └── styles.js │ ├── Grid │ │ ├── Grid.test.js │ │ ├── __snapshots__ │ │ │ └── grid.test.js.snap │ │ ├── index.js │ │ └── styles.js │ ├── GridItem │ │ ├── GridItem.test.js │ │ ├── __snapshots__ │ │ │ └── gridItem.test.js.snap │ │ ├── index.js │ │ └── styles.js │ ├── List │ │ ├── __snapshots__ │ │ │ └── list.test.js.snap │ │ ├── index.js │ │ ├── list.test.js │ │ └── styles.js │ ├── ListItem │ │ ├── index.js │ │ ├── listItem.test.js │ │ └── styles.js │ ├── Loader │ │ ├── Loader.test.js │ │ ├── __snapshots__ │ │ │ └── Loader.test.js.snap │ │ ├── index.js │ │ └── styles.js │ └── Nav │ │ ├── Nav.js │ │ ├── Nav.test.js │ │ ├── __snapshots__ │ │ └── Nav.test.js.snap │ │ ├── index.js │ │ └── styles.js ├── index.js ├── registerServiceWorker.js ├── services │ ├── Api.js │ └── hackerNewsApi.js ├── setupTests.js ├── store │ ├── app │ │ ├── actions.js │ │ ├── actions.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ └── utils.js │ ├── index.js │ ├── middleware │ │ ├── index.js │ │ └── localStorageMiddleware │ │ │ ├── hasLocalStorage.js │ │ │ ├── index.js │ │ │ ├── loadInitialState.js │ │ │ ├── loadState.js │ │ │ ├── saveState.js │ │ │ └── storageDefinitions.js │ ├── reducer.js │ ├── story │ │ ├── actions.js │ │ ├── reducer.js │ │ └── selectors.js │ └── utils │ │ └── index.js ├── styles │ ├── globals.js │ ├── mediaQueries.js │ └── palette.js └── utils │ ├── getArticleLink.js │ ├── getArticleLink.test.js │ ├── getSiteHostname.js │ └── getSiteHostname.test.js └── tutorial ├── TUTORIAL.md └── meta.json /.env: -------------------------------------------------------------------------------- 1 | NODE_PATH=src -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public/ 2 | node_modules/ 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Created by [Skilled.dev - Coding Interview Course](https://skilled.dev) and [gitconnected - Developer News and Coding Tutorials](https://gitconnected.com) 2 | 3 | ## Project Overview 4 | 5 | A Hacker News clone (in night mode!) using all open source technology. 6 | 7 | Try it out: [Download the Chrome Extension](https://chrome.google.com/webstore/detail/hacker-news/hknoigmfpgfdkccnkbfbjfnocoegoefe?pli=1&authuser=1) 8 | 9 | Design inspired by [Daily Now](https://www.dailynow.co/) - Curated dev news for busy developers 10 | 11 | Use our solution or following along with our tutorial to build your own using React and Redux. 12 | 13 | ## What You'll Learn 14 | 15 | - React 16 | - Redux 17 | - Styled Components 18 | - CSS Grid 19 | - CSS Flex 20 | - CSS Animations 21 | - Using an API 22 | - Axios for network requests 23 | - Redux middleware 24 | - Create React App to bootstrap a project 25 | - localStorage for persisting state 26 | 27 | 28 | ## Step-by-step Tutorial 29 | 30 | **Written tutorial** 31 | Follow along and move your own pace using the [gitconnected tutorial](https://gitconnected.com/courses/learn-react-redux-tutorial-build-a-hacker-news-clone). 32 | 33 | **Video Tutorial** 34 | http://www.youtube.com/watch?v=oGB_VPrld0U&index=2&list=PLTTC1K14KAxHj6AftnRUD28SQaoVauvl3 35 | [![React Tutorial](http://img.youtube.com/vi/oGB_VPrld0U/0.jpg)](http://www.youtube.com/watch?v=oGB_VPrld0U&index=2&list=PLTTC1K14KAxHj6AftnRUD28SQaoVauvl3) 36 | 37 | ## Getting Started 38 | 39 | Run the completed project to understand what you're building 40 | 41 | ```sh 42 | # Clone the project 43 | git clone https://github.com/gitconnected/hacker-news-reader.git 44 | 45 | # Install dependencies 46 | npm install 47 | 48 | # Start the project 49 | npm run start 50 | 51 | # Navigate to http://localhost:3000 52 | ``` 53 | 54 | To build your own from scratch, first initialize the project. Our solution uses [Create React App](https://github.com/facebook/create-react-app). 55 | 56 | Review the [Hacker New API](https://github.com/HackerNews/API) 57 | documentation. You will need the `/topstories` endpoint to get the list of the 58 | story IDs and the `/item` endpoint to get the data for each story individually. 59 | 60 | Finally, get your project into production. We chose to use a Chrome Extension, but 61 | you can host it in any manner than you wish. GitHub Pages is a great option and we will show you a simple way to deploy any Create React Project to GH Pages. The final solution should show a list of the top stories on Hacker News. 62 | 63 | Join the [Slack channel](https://community.gitconnected.com) to 64 | collaborate and get help. This project can be difficult, so make sure you work on it with some friends! 65 | 66 | ## Demo 67 | 68 | ![Hacktoberfest Demo](https://media.giphy.com/media/3HwA2U7rZgn0pRnV9f/giphy.gif 'Hacktoberfest Demo') 69 | 70 | ![Hacktoberfest Screenshot](https://github.com/gitconnected/hacker-news-reader/raw/master/public/hacktoberfest-screenshot.png 'Hacktoberfest Screenshot') 71 | -------------------------------------------------------------------------------- /data-for-testing.js: -------------------------------------------------------------------------------- 1 | export const stories = [ 2 | { 3 | by: 'magoghm', 4 | descendants: 41, 5 | id: 18277531, 6 | kids: [18277926, 18277900, 18278074, 18277699, 18277859, 18277835, 18277864], 7 | score: 79, 8 | time: 1540236259, 9 | title: 'AWS CEO Jassy follows Apple in calling for retraction of Chinese spy chip story', 10 | type: 'story', 11 | url: 12 | 'https://www.cnbc.com/2018/10/22/aws-ceo-jassy-follows-apple-calls-for-spy-chip-story-retraction.html', 13 | }, 14 | { 15 | by: 'NN88', 16 | descendants: 72, 17 | id: 18276862, 18 | kids: [ 19 | 18277851, 20 | 18277773, 21 | 18277336, 22 | 18277638, 23 | 18277523, 24 | 18277196, 25 | 18277173, 26 | 18277302, 27 | 18277409, 28 | 18277700, 29 | 18277870, 30 | 18277390, 31 | 18277646, 32 | 18277581, 33 | 18277721, 34 | 18277324, 35 | 18277689, 36 | 18277514, 37 | 18277389, 38 | ], 39 | score: 105, 40 | time: 1540231727, 41 | title: 'The Secretive Business of Facial-Recognition Software in Retail Stores', 42 | type: 'story', 43 | url: 44 | 'http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html', 45 | }, 46 | ]; 47 | 48 | // we need time to be fixed relative to now (like 2 hours ago), 49 | // we need the number of seconds since 1970. 50 | // Note, because the props or the way the time is displayed always changes, we can't do snapshot on this component 51 | var time = Date.now() * 1 - 1000 * 3600 * 3; 52 | time = Math.round(time / 1000); 53 | 54 | export const listItemData = { 55 | by: 'NN88', 56 | kids: [ 57 | 18277581, 58 | 18277336, 59 | 18277409, 60 | 18277173, 61 | 18277196, 62 | 18277523, 63 | 18277390, 64 | 18277302, 65 | 18277324, 66 | 18277514, 67 | 18277389, 68 | ], 69 | score: 58, 70 | url: 71 | 'http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html', 72 | title: 'The Secretive Business of Facial-Recognition Software in Retail Stores', 73 | id: 18276862, 74 | type: 'story', 75 | time: time, 76 | }; 77 | 78 | export const gridItemData = { 79 | url: 80 | 'http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html', 81 | title: 'The Secretive Business of Facial-Recognition Software in Retail Stores', 82 | id: 18276862, 83 | }; 84 | 85 | export const AppData = { 86 | hasMoreStores: false, 87 | isFetching: false, 88 | layout: 'list', 89 | page: 1, 90 | stories: stories, 91 | storyIds: [18277531, 18276862], 92 | theme: 'dark', 93 | }; 94 | 95 | export const NavData = { 96 | layout: 'list', 97 | theme: 'dark', 98 | }; 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hacker-news-reader", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.18.0", 7 | "enzyme": "^3.7.0", 8 | "enzyme-adapter-react-16": "^1.6.0", 9 | "react": "^16.7.0", 10 | "react-dom": "^16.7.0", 11 | "react-infinite-scroll-component": "^4.2.0", 12 | "react-redux": "^6.0.0", 13 | "react-scripts": "2.1.2", 14 | "react-test-renderer": "^16.7.0", 15 | "react-timeago": "^4.1.9", 16 | "redux":"4.0.1", 17 | "redux-logger": "^3.0.6", 18 | "redux-thunk": "^2.3.0", 19 | "reselect": "^4.0.0", 20 | "styled-components": "^4.1.3", 21 | "url": "^0.11.0" 22 | }, 23 | "scripts": { 24 | "start": "NODE_PATH=src react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "NODE_PATH=src react-scripts test --env=jsdom", 27 | "coverage": "npm run test -- --coverage", 28 | "eject": "react-scripts eject", 29 | "lint": "./node_modules/.bin/eslint '**/*.{js,jsx}' --quiet", 30 | "format": "./node_modules/.bin/prettier --write \"src/**/*.{js,jsx}\"" 31 | }, 32 | "devDependencies": { 33 | "eslint": "^5.11.1 ", 34 | "eslint-plugin-import": "^2.14.0", 35 | "eslint-plugin-jsx-a11y": "^6.1.2", 36 | "eslint-plugin-prettier": "^3.0.1", 37 | "eslint-plugin-react": "^7.12.0", 38 | "prettier": "^1.15.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitconnected/hacker-news-reader/47bcfbbd4721228df06ee5c0e3c51587c25ac28d/public/favicon.ico -------------------------------------------------------------------------------- /public/hacktoberfest-react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitconnected/hacker-news-reader/47bcfbbd4721228df06ee5c0e3c51587c25ac28d/public/hacktoberfest-react.png -------------------------------------------------------------------------------- /public/hacktoberfest-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitconnected/hacker-news-reader/47bcfbbd4721228df06ee5c0e3c51587c25ac28d/public/hacktoberfest-screenshot.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | Hacker News Reader by gitconnected 24 | 26 | 27 | 28 | 29 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Hacker New Reacer", 3 | "name": "Hacker News Reader by gitconnected", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ThemeProvider } from 'styled-components'; 4 | import InfiniteScroll from 'react-infinite-scroll-component'; 5 | import Nav from 'components/Nav'; 6 | import List from 'components/List'; 7 | import Grid from 'components/Grid'; 8 | import Loader from 'components/Loader'; 9 | import { layouts, themes } from 'store/app/utils'; 10 | import { colorsDark, colorsLight } from 'styles/palette'; 11 | 12 | import { Wrapper, Title, TitleWrapper, GithubLink, SocialLink } from './styles'; 13 | 14 | class App extends Component { 15 | static defaultProps = { 16 | stories: [], 17 | }; 18 | 19 | static propTypes = { 20 | layout: PropTypes.string.isRequired, 21 | theme: PropTypes.string.isRequired, 22 | stories: PropTypes.array.isRequired, 23 | page: PropTypes.number.isRequired, 24 | storyIds: PropTypes.array.isRequired, 25 | isFetching: PropTypes.bool.isRequired, 26 | hasMoreStores: PropTypes.bool.isRequired, 27 | fetchStories: PropTypes.func.isRequired, 28 | fetchStoriesFirstPage: PropTypes.func.isRequired, 29 | }; 30 | 31 | componentDidMount() { 32 | this.props.fetchStoriesFirstPage(); 33 | this.setBodyBackgroundColor(); 34 | } 35 | 36 | componentDidUpdate(prevProps) { 37 | if (prevProps.theme !== this.props.theme) { 38 | this.setBodyBackgroundColor(); 39 | } 40 | } 41 | 42 | setBodyBackgroundColor() { 43 | if (this.props.theme === themes.light) { 44 | document.body.style = `background-color: ${colorsLight.background};`; 45 | } else { 46 | document.body.style = `background-color: ${colorsDark.background};`; 47 | } 48 | } 49 | 50 | fetchStories = () => { 51 | const { storyIds, page, fetchStories, isFetching } = this.props; 52 | if (!isFetching) { 53 | fetchStories({ storyIds, page }); 54 | } 55 | }; 56 | 57 | render() { 58 | const { stories, layout, theme, hasMoreStores } = this.props; 59 | return ( 60 | 61 |
62 |
106 |
107 | ); 108 | } 109 | } 110 | 111 | export default App; 112 | -------------------------------------------------------------------------------- /src/components/App/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppData } from '../../../data-for-testing'; 3 | import { shallow } from 'enzyme'; 4 | import App from './App'; 5 | const { hasMoreStores, isFetching, layout, page, stories, storyIds, theme } = AppData; 6 | 7 | describe('App Component', () => { 8 | let wrapper; 9 | const mockfetchStories = jest.fn(); 10 | const mockfetchStoriesFirstPage = jest.fn(); 11 | beforeEach(() => { 12 | mockfetchStoriesFirstPage.mockClear(); 13 | // pass the mock function as the login prop 14 | wrapper = shallow( 15 | , 26 | ); 27 | }); 28 | 29 | it('renders without crashing', () => { 30 | expect(wrapper).toMatchSnapshot(); 31 | }); 32 | 33 | it('should call the mock fetchSotriesFistPage function on Mount', () => { 34 | expect(mockfetchStoriesFirstPage.mock.calls.length).toBe(1); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import actions from 'store/story/actions'; 3 | import { hasMoreStoriesSelector } from 'store/story/selectors'; 4 | import App from './App'; 5 | 6 | const mapStateToProps = state => ({ 7 | layout: state.app.layout, 8 | theme: state.app.theme, 9 | stories: state.story.stories, 10 | page: state.story.page, 11 | storyIds: state.story.storyIds, 12 | isFetching: state.story.isFetching, 13 | hasMoreStores: hasMoreStoriesSelector(state), 14 | }); 15 | 16 | const mapDispatchToProps = dispatch => ({ 17 | fetchStories: ({ storyIds, page }) => dispatch(actions.fetchStories({ storyIds, page })), 18 | fetchStoriesFirstPage: () => dispatch(actions.fetchStoryIds()), 19 | }); 20 | 21 | export default connect( 22 | mapStateToProps, 23 | mapDispatchToProps, 24 | )(App); 25 | -------------------------------------------------------------------------------- /src/components/App/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { mobile, tablet } from 'styles/mediaQueries'; 3 | 4 | export const Wrapper = styled.div` 5 | width: 85%; 6 | margin-left: auto; 7 | margin-right: auto; 8 | height: 100%; 9 | overflow: hidden; 10 | padding-bottom: 200px; 11 | 12 | ${tablet} { 13 | width: 96%; 14 | } 15 | `; 16 | 17 | export const Title = styled.h1` 18 | color: ${({ theme }) => theme.textSecondary}; 19 | font-size: 20px; 20 | font-weight: 300; 21 | `; 22 | 23 | export const TitleWrapper = styled.div` 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: center; 27 | margin-top: 24px; 28 | margin-bottom: 26px; 29 | 30 | ${mobile} { 31 | flex-direction: column; 32 | align-items: flex-start; 33 | } 34 | `; 35 | 36 | export const LinkWrapper = styled.div` 37 | display: flex; 38 | `; 39 | 40 | export const SocialLink = styled.a` 41 | margin-left: 16px; 42 | 43 | i { 44 | color: ${({ theme }) => theme.text}; 45 | } 46 | 47 | ${mobile} { 48 | margin-left: 0; 49 | margin-right: 16px; 50 | } 51 | `; 52 | 53 | export const GithubLink = styled.a` 54 | color: ${({ theme }) => theme.textSecondary}; 55 | font-size: 14px; 56 | text-decoration: underline; 57 | 58 | &:visited { 59 | color: ${({ theme }) => theme.textSecondary}; 60 | } 61 | `; 62 | -------------------------------------------------------------------------------- /src/components/Grid/Grid.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { stories } from '../../../data-for-testing'; 3 | import { shallow } from 'enzyme'; 4 | import Grid from './index'; 5 | 6 | it('renders without crashing', () => { 7 | const component = shallow(); 8 | expect(component).toMatchSnapshot(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/Grid/__snapshots__/grid.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders without crashing 1`] = ` 4 | ShallowWrapper { 5 | Symbol(enzyme.__root__): [Circular], 6 | Symbol(enzyme.__unrendered__): , 62 | Symbol(enzyme.__renderer__): Object { 63 | "batchedUpdates": [Function], 64 | "getNode": [Function], 65 | "render": [Function], 66 | "simulateError": [Function], 67 | "simulateEvent": [Function], 68 | "unmount": [Function], 69 | }, 70 | Symbol(enzyme.__node__): Object { 71 | "instance": null, 72 | "key": undefined, 73 | "nodeType": "class", 74 | "props": Object { 75 | "children": Array [ 76 | , 97 | , 130 | ], 131 | }, 132 | "ref": null, 133 | "rendered": Array [ 134 | Object { 135 | "instance": null, 136 | "key": "18277531", 137 | "nodeType": "function", 138 | "props": Object { 139 | "by": "magoghm", 140 | "descendants": 41, 141 | "id": 18277531, 142 | "kids": Array [ 143 | 18277926, 144 | 18277900, 145 | 18278074, 146 | 18277699, 147 | 18277859, 148 | 18277835, 149 | 18277864, 150 | ], 151 | "score": 79, 152 | "time": 1540236259, 153 | "title": "AWS CEO Jassy follows Apple in calling for retraction of Chinese spy chip story", 154 | "type": "story", 155 | "url": "https://www.cnbc.com/2018/10/22/aws-ceo-jassy-follows-apple-calls-for-spy-chip-story-retraction.html", 156 | }, 157 | "ref": null, 158 | "rendered": null, 159 | "type": [Function], 160 | }, 161 | Object { 162 | "instance": null, 163 | "key": "18276862", 164 | "nodeType": "function", 165 | "props": Object { 166 | "by": "NN88", 167 | "descendants": 72, 168 | "id": 18276862, 169 | "kids": Array [ 170 | 18277851, 171 | 18277773, 172 | 18277336, 173 | 18277638, 174 | 18277523, 175 | 18277196, 176 | 18277173, 177 | 18277302, 178 | 18277409, 179 | 18277700, 180 | 18277870, 181 | 18277390, 182 | 18277646, 183 | 18277581, 184 | 18277721, 185 | 18277324, 186 | 18277689, 187 | 18277514, 188 | 18277389, 189 | ], 190 | "score": 105, 191 | "time": 1540231727, 192 | "title": "The Secretive Business of Facial-Recognition Software in Retail Stores", 193 | "type": "story", 194 | "url": "http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html", 195 | }, 196 | "ref": null, 197 | "rendered": null, 198 | "type": [Function], 199 | }, 200 | ], 201 | "type": [Function], 202 | }, 203 | Symbol(enzyme.__nodes__): Array [ 204 | Object { 205 | "instance": null, 206 | "key": undefined, 207 | "nodeType": "class", 208 | "props": Object { 209 | "children": Array [ 210 | , 231 | , 264 | ], 265 | }, 266 | "ref": null, 267 | "rendered": Array [ 268 | Object { 269 | "instance": null, 270 | "key": "18277531", 271 | "nodeType": "function", 272 | "props": Object { 273 | "by": "magoghm", 274 | "descendants": 41, 275 | "id": 18277531, 276 | "kids": Array [ 277 | 18277926, 278 | 18277900, 279 | 18278074, 280 | 18277699, 281 | 18277859, 282 | 18277835, 283 | 18277864, 284 | ], 285 | "score": 79, 286 | "time": 1540236259, 287 | "title": "AWS CEO Jassy follows Apple in calling for retraction of Chinese spy chip story", 288 | "type": "story", 289 | "url": "https://www.cnbc.com/2018/10/22/aws-ceo-jassy-follows-apple-calls-for-spy-chip-story-retraction.html", 290 | }, 291 | "ref": null, 292 | "rendered": null, 293 | "type": [Function], 294 | }, 295 | Object { 296 | "instance": null, 297 | "key": "18276862", 298 | "nodeType": "function", 299 | "props": Object { 300 | "by": "NN88", 301 | "descendants": 72, 302 | "id": 18276862, 303 | "kids": Array [ 304 | 18277851, 305 | 18277773, 306 | 18277336, 307 | 18277638, 308 | 18277523, 309 | 18277196, 310 | 18277173, 311 | 18277302, 312 | 18277409, 313 | 18277700, 314 | 18277870, 315 | 18277390, 316 | 18277646, 317 | 18277581, 318 | 18277721, 319 | 18277324, 320 | 18277689, 321 | 18277514, 322 | 18277389, 323 | ], 324 | "score": 105, 325 | "time": 1540231727, 326 | "title": "The Secretive Business of Facial-Recognition Software in Retail Stores", 327 | "type": "story", 328 | "url": "http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html", 329 | }, 330 | "ref": null, 331 | "rendered": null, 332 | "type": [Function], 333 | }, 334 | ], 335 | "type": [Function], 336 | }, 337 | ], 338 | Symbol(enzyme.__options__): Object { 339 | "adapter": ReactSixteenAdapter { 340 | "options": Object { 341 | "enableComponentDidUpdateOnSetState": true, 342 | "lifecycles": Object { 343 | "componentDidUpdate": Object { 344 | "onSetState": true, 345 | }, 346 | "getDerivedStateFromProps": true, 347 | "getSnapshotBeforeUpdate": true, 348 | "setState": Object { 349 | "skipsComponentDidUpdateOnNullish": true, 350 | }, 351 | }, 352 | }, 353 | }, 354 | }, 355 | } 356 | `; 357 | -------------------------------------------------------------------------------- /src/components/Grid/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import GridItem from 'components/GridItem'; 4 | 5 | import { GridWrapper } from './styles'; 6 | 7 | class Grid extends Component { 8 | static propTypes = { 9 | stories: PropTypes.arrayOf( 10 | PropTypes.shape({ 11 | id: PropTypes.number.isRequired, 12 | }), 13 | ).isRequired, 14 | }; 15 | 16 | render() { 17 | return ( 18 | 19 | {this.props.stories.map(story => ( 20 | 21 | ))} 22 | 23 | ); 24 | } 25 | } 26 | 27 | export default Grid; 28 | -------------------------------------------------------------------------------- /src/components/Grid/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { mobile, tablet, monitor } from 'styles/mediaQueries'; 3 | 4 | export const GridWrapper = styled.ul` 5 | display: flex; 6 | margin-bottom: 20px; 7 | display: grid; 8 | grid-template-columns: repeat(3, 1fr); 9 | grid-gap: 32px; 10 | 11 | ${monitor} { 12 | grid-template-columns: repeat(4, 1fr); 13 | } 14 | 15 | ${tablet} { 16 | grid-template-columns: repeat(2, 1fr); 17 | } 18 | 19 | ${mobile} { 20 | grid-template-columns: 1fr; 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /src/components/GridItem/GridItem.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { gridItemData } from '../../../data-for-testing'; 3 | import { shallow } from 'enzyme'; 4 | import GridItem from './index'; 5 | const { url, title, id } = gridItemData; 6 | 7 | it('renders without crashing', () => { 8 | const component = shallow(); 9 | expect(component).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/GridItem/__snapshots__/gridItem.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders without crashing 1`] = ` 4 | ShallowWrapper { 5 | Symbol(enzyme.__root__): [Circular], 6 | Symbol(enzyme.__unrendered__): , 11 | Symbol(enzyme.__renderer__): Object { 12 | "batchedUpdates": [Function], 13 | "getNode": [Function], 14 | "render": [Function], 15 | "simulateError": [Function], 16 | "simulateEvent": [Function], 17 | "unmount": [Function], 18 | }, 19 | Symbol(enzyme.__node__): Object { 20 | "instance": null, 21 | "key": undefined, 22 | "nodeType": "host", 23 | "props": Object { 24 | "children": 25 | 26 | 29 | 30 | 31 | The Secretive Business of Facial-Recognition Software in Retail Stores 32 | 33 | 34 | // 35 | nymag.com 36 | 37 | 38 | 39 | , 40 | "href": "http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html", 41 | "rel": "nofollow noreferrer nofollow", 42 | "target": "_blank", 43 | }, 44 | "ref": null, 45 | "rendered": Object { 46 | "instance": null, 47 | "key": undefined, 48 | "nodeType": "class", 49 | "props": Object { 50 | "children": 51 | 54 | 55 | 56 | The Secretive Business of Facial-Recognition Software in Retail Stores 57 | 58 | 59 | // 60 | nymag.com 61 | 62 | 63 | , 64 | }, 65 | "ref": null, 66 | "rendered": Object { 67 | "instance": null, 68 | "key": undefined, 69 | "nodeType": "class", 70 | "props": Object { 71 | "children": Array [ 72 | , 75 | 76 | 77 | The Secretive Business of Facial-Recognition Software in Retail Stores 78 | 79 | 80 | // 81 | nymag.com 82 | 83 | , 84 | ], 85 | }, 86 | "ref": null, 87 | "rendered": Array [ 88 | Object { 89 | "instance": null, 90 | "key": undefined, 91 | "nodeType": "class", 92 | "props": Object { 93 | "src": "https://miro.medium.com/max/1176/1*F9RzuXseG1VrTjFJd403gw.png", 94 | }, 95 | "ref": null, 96 | "rendered": null, 97 | "type": [Function], 98 | }, 99 | Object { 100 | "instance": null, 101 | "key": undefined, 102 | "nodeType": "class", 103 | "props": Object { 104 | "children": Array [ 105 | 106 | The Secretive Business of Facial-Recognition Software in Retail Stores 107 | , 108 | 109 | // 110 | nymag.com 111 | , 112 | ], 113 | }, 114 | "ref": null, 115 | "rendered": Array [ 116 | Object { 117 | "instance": null, 118 | "key": undefined, 119 | "nodeType": "class", 120 | "props": Object { 121 | "children": "The Secretive Business of Facial-Recognition Software in Retail Stores", 122 | }, 123 | "ref": null, 124 | "rendered": "The Secretive Business of Facial-Recognition Software in Retail Stores", 125 | "type": [Function], 126 | }, 127 | Object { 128 | "instance": null, 129 | "key": undefined, 130 | "nodeType": "class", 131 | "props": Object { 132 | "children": Array [ 133 | "// ", 134 | "nymag.com", 135 | ], 136 | }, 137 | "ref": null, 138 | "rendered": Array [ 139 | "// ", 140 | "nymag.com", 141 | ], 142 | "type": [Function], 143 | }, 144 | ], 145 | "type": [Function], 146 | }, 147 | ], 148 | "type": [Function], 149 | }, 150 | "type": [Function], 151 | }, 152 | "type": "a", 153 | }, 154 | Symbol(enzyme.__nodes__): Array [ 155 | Object { 156 | "instance": null, 157 | "key": undefined, 158 | "nodeType": "host", 159 | "props": Object { 160 | "children": 161 | 162 | 165 | 166 | 167 | The Secretive Business of Facial-Recognition Software in Retail Stores 168 | 169 | 170 | // 171 | nymag.com 172 | 173 | 174 | 175 | , 176 | "href": "http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html", 177 | "rel": "nofollow noreferrer nofollow", 178 | "target": "_blank", 179 | }, 180 | "ref": null, 181 | "rendered": Object { 182 | "instance": null, 183 | "key": undefined, 184 | "nodeType": "class", 185 | "props": Object { 186 | "children": 187 | 190 | 191 | 192 | The Secretive Business of Facial-Recognition Software in Retail Stores 193 | 194 | 195 | // 196 | nymag.com 197 | 198 | 199 | , 200 | }, 201 | "ref": null, 202 | "rendered": Object { 203 | "instance": null, 204 | "key": undefined, 205 | "nodeType": "class", 206 | "props": Object { 207 | "children": Array [ 208 | , 211 | 212 | 213 | The Secretive Business of Facial-Recognition Software in Retail Stores 214 | 215 | 216 | // 217 | nymag.com 218 | 219 | , 220 | ], 221 | }, 222 | "ref": null, 223 | "rendered": Array [ 224 | Object { 225 | "instance": null, 226 | "key": undefined, 227 | "nodeType": "class", 228 | "props": Object { 229 | "src": "https://miro.medium.com/max/1176/1*F9RzuXseG1VrTjFJd403gw.png", 230 | }, 231 | "ref": null, 232 | "rendered": null, 233 | "type": [Function], 234 | }, 235 | Object { 236 | "instance": null, 237 | "key": undefined, 238 | "nodeType": "class", 239 | "props": Object { 240 | "children": Array [ 241 | 242 | The Secretive Business of Facial-Recognition Software in Retail Stores 243 | , 244 | 245 | // 246 | nymag.com 247 | , 248 | ], 249 | }, 250 | "ref": null, 251 | "rendered": Array [ 252 | Object { 253 | "instance": null, 254 | "key": undefined, 255 | "nodeType": "class", 256 | "props": Object { 257 | "children": "The Secretive Business of Facial-Recognition Software in Retail Stores", 258 | }, 259 | "ref": null, 260 | "rendered": "The Secretive Business of Facial-Recognition Software in Retail Stores", 261 | "type": [Function], 262 | }, 263 | Object { 264 | "instance": null, 265 | "key": undefined, 266 | "nodeType": "class", 267 | "props": Object { 268 | "children": Array [ 269 | "// ", 270 | "nymag.com", 271 | ], 272 | }, 273 | "ref": null, 274 | "rendered": Array [ 275 | "// ", 276 | "nymag.com", 277 | ], 278 | "type": [Function], 279 | }, 280 | ], 281 | "type": [Function], 282 | }, 283 | ], 284 | "type": [Function], 285 | }, 286 | "type": [Function], 287 | }, 288 | "type": "a", 289 | }, 290 | ], 291 | Symbol(enzyme.__options__): Object { 292 | "adapter": ReactSixteenAdapter { 293 | "options": Object { 294 | "enableComponentDidUpdateOnSetState": true, 295 | "lifecycles": Object { 296 | "componentDidUpdate": Object { 297 | "onSetState": true, 298 | }, 299 | "getDerivedStateFromProps": true, 300 | "getSnapshotBeforeUpdate": true, 301 | "setState": Object { 302 | "skipsComponentDidUpdateOnNullish": true, 303 | }, 304 | }, 305 | }, 306 | }, 307 | }, 308 | } 309 | `; 310 | -------------------------------------------------------------------------------- /src/components/GridItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import getSiteHostname from 'utils/getSiteHostname'; 4 | import getArticleLink from 'utils/getArticleLink'; 5 | 6 | import { Item, Card, Image, Content, Title, Source } from './styles'; 7 | 8 | const GridItem = ({ url, title, id }) => { 9 | const site = getSiteHostname(url) || 'news.ycombinator.com'; 10 | const link = getArticleLink({ url, id }); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | {title} 19 | 20 |
{`// ${site}`}
21 | 22 |
23 |
24 |
25 |
26 | ); 27 | }; 28 | 29 | GridItem.propTypes = { 30 | url: PropTypes.string, 31 | title: PropTypes.string.isRequired, 32 | id: PropTypes.number.isRequired, 33 | }; 34 | 35 | export default GridItem; 36 | -------------------------------------------------------------------------------- /src/components/GridItem/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const RADIUS = 4; 4 | 5 | export const Item = styled.li` 6 | height: 100%; 7 | `; 8 | 9 | export const Card = styled.div` 10 | flex-direction: column; 11 | display: flex; 12 | height: 100%; 13 | `; 14 | 15 | export const ExternalLink = styled.a` 16 | display: flex; 17 | flex-direction: column; 18 | `; 19 | 20 | export const Image = styled.img` 21 | display: block; 22 | height: 240px; 23 | max-width: 100%; 24 | border-top-left-radius: ${RADIUS}px; 25 | border-top-right-radius: ${RADIUS}px; 26 | object-fit: cover; 27 | `; 28 | 29 | export const Content = styled.div` 30 | background-color: ${({ theme }) => theme.backgroundSecondary}; 31 | border-bottom-left-radius: ${RADIUS}px; 32 | border-bottom-right-radius: ${RADIUS}px; 33 | word-break: break-word; 34 | display: flex; 35 | flex-direction: column; 36 | flex-grow: 1; 37 | `; 38 | 39 | export const Title = styled.h3` 40 | color: ${({ theme }) => theme.text}; 41 | margin: 0; 42 | padding: 16px; 43 | padding-bottom: 70px; 44 | font-size: 16px; 45 | font-weight: 400; 46 | flex-grow: 1; 47 | `; 48 | 49 | export const Source = styled.div` 50 | color: ${({ theme }) => theme.textSecondary}; 51 | border-top: 1px solid ${({ theme }) => theme.border}; 52 | padding: 16px; 53 | `; 54 | -------------------------------------------------------------------------------- /src/components/List/__snapshots__/list.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders without crashing 1`] = ` 4 | ShallowWrapper { 5 | Symbol(enzyme.__root__): [Circular], 6 | Symbol(enzyme.__unrendered__): , 62 | Symbol(enzyme.__renderer__): Object { 63 | "batchedUpdates": [Function], 64 | "getNode": [Function], 65 | "render": [Function], 66 | "simulateError": [Function], 67 | "simulateEvent": [Function], 68 | "unmount": [Function], 69 | }, 70 | Symbol(enzyme.__node__): Object { 71 | "instance": null, 72 | "key": undefined, 73 | "nodeType": "class", 74 | "props": Object { 75 | "children": Array [ 76 | , 97 | , 130 | ], 131 | }, 132 | "ref": null, 133 | "rendered": Array [ 134 | Object { 135 | "instance": null, 136 | "key": "18277531", 137 | "nodeType": "function", 138 | "props": Object { 139 | "by": "magoghm", 140 | "descendants": 41, 141 | "id": 18277531, 142 | "kids": Array [ 143 | 18277926, 144 | 18277900, 145 | 18278074, 146 | 18277699, 147 | 18277859, 148 | 18277835, 149 | 18277864, 150 | ], 151 | "score": 79, 152 | "time": 1540236259, 153 | "title": "AWS CEO Jassy follows Apple in calling for retraction of Chinese spy chip story", 154 | "type": "story", 155 | "url": "https://www.cnbc.com/2018/10/22/aws-ceo-jassy-follows-apple-calls-for-spy-chip-story-retraction.html", 156 | }, 157 | "ref": null, 158 | "rendered": null, 159 | "type": [Function], 160 | }, 161 | Object { 162 | "instance": null, 163 | "key": "18276862", 164 | "nodeType": "function", 165 | "props": Object { 166 | "by": "NN88", 167 | "descendants": 72, 168 | "id": 18276862, 169 | "kids": Array [ 170 | 18277851, 171 | 18277773, 172 | 18277336, 173 | 18277638, 174 | 18277523, 175 | 18277196, 176 | 18277173, 177 | 18277302, 178 | 18277409, 179 | 18277700, 180 | 18277870, 181 | 18277390, 182 | 18277646, 183 | 18277581, 184 | 18277721, 185 | 18277324, 186 | 18277689, 187 | 18277514, 188 | 18277389, 189 | ], 190 | "score": 105, 191 | "time": 1540231727, 192 | "title": "The Secretive Business of Facial-Recognition Software in Retail Stores", 193 | "type": "story", 194 | "url": "http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html", 195 | }, 196 | "ref": null, 197 | "rendered": null, 198 | "type": [Function], 199 | }, 200 | ], 201 | "type": [Function], 202 | }, 203 | Symbol(enzyme.__nodes__): Array [ 204 | Object { 205 | "instance": null, 206 | "key": undefined, 207 | "nodeType": "class", 208 | "props": Object { 209 | "children": Array [ 210 | , 231 | , 264 | ], 265 | }, 266 | "ref": null, 267 | "rendered": Array [ 268 | Object { 269 | "instance": null, 270 | "key": "18277531", 271 | "nodeType": "function", 272 | "props": Object { 273 | "by": "magoghm", 274 | "descendants": 41, 275 | "id": 18277531, 276 | "kids": Array [ 277 | 18277926, 278 | 18277900, 279 | 18278074, 280 | 18277699, 281 | 18277859, 282 | 18277835, 283 | 18277864, 284 | ], 285 | "score": 79, 286 | "time": 1540236259, 287 | "title": "AWS CEO Jassy follows Apple in calling for retraction of Chinese spy chip story", 288 | "type": "story", 289 | "url": "https://www.cnbc.com/2018/10/22/aws-ceo-jassy-follows-apple-calls-for-spy-chip-story-retraction.html", 290 | }, 291 | "ref": null, 292 | "rendered": null, 293 | "type": [Function], 294 | }, 295 | Object { 296 | "instance": null, 297 | "key": "18276862", 298 | "nodeType": "function", 299 | "props": Object { 300 | "by": "NN88", 301 | "descendants": 72, 302 | "id": 18276862, 303 | "kids": Array [ 304 | 18277851, 305 | 18277773, 306 | 18277336, 307 | 18277638, 308 | 18277523, 309 | 18277196, 310 | 18277173, 311 | 18277302, 312 | 18277409, 313 | 18277700, 314 | 18277870, 315 | 18277390, 316 | 18277646, 317 | 18277581, 318 | 18277721, 319 | 18277324, 320 | 18277689, 321 | 18277514, 322 | 18277389, 323 | ], 324 | "score": 105, 325 | "time": 1540231727, 326 | "title": "The Secretive Business of Facial-Recognition Software in Retail Stores", 327 | "type": "story", 328 | "url": "http://nymag.com/intelligencer/2018/10/retailers-are-using-facial-recognition-technology-too.html", 329 | }, 330 | "ref": null, 331 | "rendered": null, 332 | "type": [Function], 333 | }, 334 | ], 335 | "type": [Function], 336 | }, 337 | ], 338 | Symbol(enzyme.__options__): Object { 339 | "adapter": ReactSixteenAdapter { 340 | "options": Object { 341 | "enableComponentDidUpdateOnSetState": true, 342 | "lifecycles": Object { 343 | "componentDidUpdate": Object { 344 | "onSetState": true, 345 | }, 346 | "getDerivedStateFromProps": true, 347 | "getSnapshotBeforeUpdate": true, 348 | "setState": Object { 349 | "skipsComponentDidUpdateOnNullish": true, 350 | }, 351 | }, 352 | }, 353 | }, 354 | }, 355 | } 356 | `; 357 | -------------------------------------------------------------------------------- /src/components/List/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ListItem from 'components/ListItem'; 4 | 5 | import { ListWrapper } from './styles'; 6 | 7 | class List extends Component { 8 | static propTypes = { 9 | stories: PropTypes.array.isRequired, 10 | }; 11 | 12 | render() { 13 | const { stories } = this.props; 14 | return ( 15 | 16 | {stories.map(story => ( 17 | 18 | ))} 19 | 20 | ); 21 | } 22 | } 23 | 24 | export default List; 25 | -------------------------------------------------------------------------------- /src/components/List/list.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { stories } from '../../../data-for-testing'; 3 | import { shallow } from 'enzyme'; 4 | import List from './index'; 5 | 6 | it('renders without crashing', () => { 7 | const component = shallow(); 8 | expect(component).toMatchSnapshot(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/List/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ListWrapper = styled.ul` 4 | background-color: ${({ theme }) => theme.backgroundSecondary}; 5 | border-radius: 4px; 6 | margin-left: auto; 7 | margin-right: auto; 8 | margin-bottom: 20px; 9 | display: flex; 10 | flex-direction: column; 11 | `; 12 | -------------------------------------------------------------------------------- /src/components/ListItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TimeAgo from 'react-timeago'; 4 | import getSiteHostname from 'utils/getSiteHostname'; 5 | import getArticleLink, { HN_USER, HN_ITEM } from 'utils/getArticleLink'; 6 | 7 | import { Item, Title, Host, ExternalLink, Description, CommentLink } from './styles'; 8 | 9 | const ListItem = ({ by, kids = [], score, url, title, id, type, time }) => { 10 | const site = getSiteHostname(url) || 'news.ycombinator.com'; 11 | const link = getArticleLink({ url, id }); 12 | const commentUrl = `${HN_ITEM}${id}`; 13 | 14 | return ( 15 | 16 | 17 | 18 | {title} <Host>({site})</Host> 19 | 20 | 21 | 22 | {score} points by{' '} 23 | 24 | {by} 25 | {' '} 26 | {' | '} 27 | 28 | {kids.length} Comments 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | ListItem.propTypes = { 36 | by: PropTypes.string.isRequired, 37 | kids: PropTypes.array, 38 | score: PropTypes.number.isRequired, 39 | url: PropTypes.string, 40 | title: PropTypes.string.isRequired, 41 | id: PropTypes.number.isRequired, 42 | type: PropTypes.string.isRequired, 43 | time: PropTypes.number.isRequired, 44 | }; 45 | 46 | export default ListItem; 47 | -------------------------------------------------------------------------------- /src/components/ListItem/listItem.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { listItemData } from '../../../data-for-testing'; 3 | import { shallow } from 'enzyme'; 4 | import ListItem from './index'; 5 | const { by, kids, score, url, title, id, type, time } = listItemData; 6 | 7 | it('renders without crashing', () => { 8 | const component = shallow( 9 | , 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/ListItem/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Item = styled.li` 4 | border-bottom: 1px solid ${({ theme }) => theme.border}; 5 | padding: 14px 24px; 6 | 7 | &:last-child { 8 | border-bottom: none; 9 | } 10 | `; 11 | 12 | export const Title = styled.h3` 13 | color: ${({ theme }) => theme.text}; 14 | margin-top: 0; 15 | margin-bottom: 6px; 16 | font-weight: 400; 17 | font-size: 16px; 18 | letter-spacing: 0.4px; 19 | `; 20 | 21 | export const Host = styled.span` 22 | color: ${({ theme }) => theme.textSecondary}; 23 | font-size: 12px; 24 | `; 25 | 26 | export const ExternalLink = styled.a` 27 | color: ${({ theme }) => theme.text}; 28 | display: flex; 29 | width: 100%; 30 | height: 100%; 31 | flex-direction: row; 32 | align-items: center; 33 | text-decoration: none; 34 | `; 35 | 36 | export const Description = styled.div` 37 | font-size: 14px; 38 | color: ${({ theme }) => theme.textSecondary}; 39 | `; 40 | 41 | export const CommentLink = styled.a` 42 | color: ${({ theme }) => theme.textSecondary}; 43 | 44 | &:visited { 45 | color: ${({ theme }) => theme.textSecondary}; 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Loader from './index'; 4 | 5 | it('renders without crashing', () => { 6 | const component = shallow(); 7 | expect(component).toMatchSnapshot(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/components/Loader/__snapshots__/Loader.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders without crashing 1`] = ` 4 | ShallowWrapper { 5 | Symbol(enzyme.__root__): [Circular], 6 | Symbol(enzyme.__unrendered__): , 7 | Symbol(enzyme.__renderer__): Object { 8 | "batchedUpdates": [Function], 9 | "getNode": [Function], 10 | "render": [Function], 11 | "simulateError": [Function], 12 | "simulateEvent": [Function], 13 | "unmount": [Function], 14 | }, 15 | Symbol(enzyme.__node__): Object { 16 | "instance": null, 17 | "key": undefined, 18 | "nodeType": "class", 19 | "props": Object { 20 | "children": Array [ 21 | 22 | . 23 | , 24 | 25 | . 26 | , 27 | 28 | . 29 | , 30 | ], 31 | }, 32 | "ref": null, 33 | "rendered": Array [ 34 | Object { 35 | "instance": null, 36 | "key": undefined, 37 | "nodeType": "host", 38 | "props": Object { 39 | "children": ".", 40 | }, 41 | "ref": null, 42 | "rendered": ".", 43 | "type": "span", 44 | }, 45 | Object { 46 | "instance": null, 47 | "key": undefined, 48 | "nodeType": "host", 49 | "props": Object { 50 | "children": ".", 51 | }, 52 | "ref": null, 53 | "rendered": ".", 54 | "type": "span", 55 | }, 56 | Object { 57 | "instance": null, 58 | "key": undefined, 59 | "nodeType": "host", 60 | "props": Object { 61 | "children": ".", 62 | }, 63 | "ref": null, 64 | "rendered": ".", 65 | "type": "span", 66 | }, 67 | ], 68 | "type": [Function], 69 | }, 70 | Symbol(enzyme.__nodes__): Array [ 71 | Object { 72 | "instance": null, 73 | "key": undefined, 74 | "nodeType": "class", 75 | "props": Object { 76 | "children": Array [ 77 | 78 | . 79 | , 80 | 81 | . 82 | , 83 | 84 | . 85 | , 86 | ], 87 | }, 88 | "ref": null, 89 | "rendered": Array [ 90 | Object { 91 | "instance": null, 92 | "key": undefined, 93 | "nodeType": "host", 94 | "props": Object { 95 | "children": ".", 96 | }, 97 | "ref": null, 98 | "rendered": ".", 99 | "type": "span", 100 | }, 101 | Object { 102 | "instance": null, 103 | "key": undefined, 104 | "nodeType": "host", 105 | "props": Object { 106 | "children": ".", 107 | }, 108 | "ref": null, 109 | "rendered": ".", 110 | "type": "span", 111 | }, 112 | Object { 113 | "instance": null, 114 | "key": undefined, 115 | "nodeType": "host", 116 | "props": Object { 117 | "children": ".", 118 | }, 119 | "ref": null, 120 | "rendered": ".", 121 | "type": "span", 122 | }, 123 | ], 124 | "type": [Function], 125 | }, 126 | ], 127 | Symbol(enzyme.__options__): Object { 128 | "adapter": ReactSixteenAdapter { 129 | "options": Object { 130 | "enableComponentDidUpdateOnSetState": true, 131 | "lifecycles": Object { 132 | "componentDidUpdate": Object { 133 | "onSetState": true, 134 | }, 135 | "getDerivedStateFromProps": true, 136 | "getSnapshotBeforeUpdate": true, 137 | "setState": Object { 138 | "skipsComponentDidUpdateOnNullish": true, 139 | }, 140 | }, 141 | }, 142 | }, 143 | }, 144 | } 145 | `; 146 | -------------------------------------------------------------------------------- /src/components/Loader/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Animation } from './styles'; 4 | 5 | const Loader = () => ( 6 | 7 | . 8 | . 9 | . 10 | 11 | ); 12 | 13 | export default Loader; 14 | -------------------------------------------------------------------------------- /src/components/Loader/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | 3 | const blink = keyframes` 4 | /** 5 | * At the start of the animation the dot 6 | * has an opacity of .2 7 | */ 8 | 0% { 9 | opacity: .2; 10 | } 11 | /** 12 | * At 20% the dot is fully visible and 13 | * then fades out slowly 14 | */ 15 | 20% { 16 | opacity: 1; 17 | } 18 | /** 19 | * Until it reaches an opacity of .2 and 20 | * the animation can start again 21 | */ 22 | 100% { 23 | opacity: .2; 24 | } 25 | `; 26 | 27 | export const Animation = styled.div` 28 | text-align: center; 29 | 30 | span { 31 | color: ${({ theme }) => theme.textSecondary}; 32 | display: inline-block; 33 | margin-left: 4px; 34 | margin-right: 4px; 35 | font-size: 80px; 36 | line-height: 0.1; 37 | 38 | /** 39 | * Use the blink animation, which is defined above 40 | */ 41 | animation-name: ${blink}; 42 | /** 43 | * The total time of animation 44 | */ 45 | animation-duration: 1s; 46 | /** 47 | * It will repeat itself forever 48 | */ 49 | animation-iteration-count: infinite; 50 | /** 51 | * This makes sure that the starting style (opacity: .2) 52 | * of the animation is applied before the animation starts. 53 | * Otherwise we would see a short flash or would have 54 | * to set the default styling of the dots to the same 55 | * as the animation. Same applies for the ending styles. 56 | */ 57 | animation-fill-mode: both; 58 | } 59 | 60 | span:nth-child(2) { 61 | animation-delay: 0.2s; 62 | } 63 | span:nth-child(3) { 64 | animation-delay: 0.4s; 65 | } 66 | `; 67 | -------------------------------------------------------------------------------- /src/components/Nav/Nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { layouts, themes } from 'store/app/utils'; 4 | 5 | import { Header, Spacer, NavSection, Content, Icon, Logo, ExternalLink } from './styles'; 6 | 7 | const Nav = ({ layout, theme, setLayout, setTheme }) => ( 8 |
9 |
10 | 11 | 12 | 13 | gitconnected 14 | 15 | 16 | 17 | {layout === layouts.list ? ( 18 | setLayout(layouts.grid)}> 19 | 20 | 21 | ) : ( 22 | setLayout(layouts.list)}> 23 | 24 | 25 | )} 26 | {theme === themes.light ? ( 27 | setTheme(themes.dark)}> 28 | 29 | 30 | ) : ( 31 | setTheme(themes.light)}> 32 | 33 | 34 | )} 35 | 36 | 37 |
38 | 39 |
40 | ); 41 | 42 | Nav.propTypes = { 43 | layout: PropTypes.string.isRequired, 44 | theme: PropTypes.string.isRequired, 45 | setLayout: PropTypes.func.isRequired, 46 | setTheme: PropTypes.func.isRequired, 47 | }; 48 | 49 | export default Nav; 50 | -------------------------------------------------------------------------------- /src/components/Nav/Nav.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavData } from '../../../data-for-testing'; 3 | import { shallow } from 'enzyme'; 4 | import Nav from './Nav'; 5 | const { layout, theme } = NavData; 6 | 7 | describe('App Component', () => { 8 | let wrapper; 9 | const mocksetLayout = jest.fn(); 10 | const mocksetTheme = jest.fn(); 11 | beforeEach(() => { 12 | // pass the mock function as the login prop 13 | wrapper = shallow( 14 |