├── .github └── workflows │ ├── ci-cd.yml │ └── pr.yml ├── .gitignore ├── README.md ├── cypress.config.js ├── cypress ├── e2e │ └── hackernewsStories.cy.js ├── fixtures │ ├── incomplete.json │ └── stories.json └── support │ ├── component-index.html │ └── component.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── components │ ├── App │ │ ├── App.cy.js │ │ ├── index.css │ │ └── index.js │ ├── Button │ │ ├── Button.cy.js │ │ ├── index.css │ │ └── index.js │ ├── Loading │ │ ├── ButtonWithLoading.js │ │ ├── Loading.cy.js │ │ ├── index.js │ │ └── withLoading.js │ ├── Search │ │ ├── Search.cy.js │ │ └── index.js │ ├── Sort │ │ ├── Sort.cy.js │ │ └── index.js │ └── Table │ │ ├── Table.cy.js │ │ ├── index.css │ │ └── index.js ├── constants │ └── index.js ├── index.css ├── index.js └── registerServiceWorker.js └── vercel.json /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: ci-cd 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | pre-deployment-tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Run component tests 🧪 16 | uses: cypress-io/github-action@v6 17 | env: 18 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 19 | with: 20 | command: npm run ct 21 | 22 | - name: Run visual 👀 tests 🧪 23 | uses: cypress-io/github-action@v6 24 | env: 25 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 26 | PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} 27 | with: 28 | install: false 29 | command: npm run vt 30 | 31 | - name: Run end-to-end tests 🧪 32 | uses: cypress-io/github-action@v6 33 | env: 34 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 35 | with: 36 | install: false 37 | start: npm start 38 | wait-on: 'http://localhost:3000' 39 | command: npm run e2e 40 | deploy: 41 | needs: pre-deployment-tests 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: amondnet/vercel-action@v25 46 | with: 47 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 48 | vercel-args: '--prod' 49 | vercel-org-id: ${{ secrets.ORG_ID}} 50 | vercel-project-id: ${{ secrets.PROJECT_ID}} 51 | post-deployment-tests: 52 | needs: deploy 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | 58 | - name: Run e2e tests in production 🧪 59 | uses: cypress-io/github-action@v6 60 | env: 61 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 62 | with: 63 | command: npm run e2e:prod 64 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | cypress-tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | 12 | - name: Run component tests 🧪 13 | uses: cypress-io/github-action@v6 14 | env: 15 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 16 | with: 17 | command: npm run ct 18 | 19 | - name: Run visual 👀 tests 🧪 20 | uses: cypress-io/github-action@v6 21 | env: 22 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 23 | PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} 24 | with: 25 | install: false 26 | command: npm run vt 27 | 28 | - name: Run end-to-end tests 🧪 29 | uses: cypress-io/github-action@v6 30 | env: 31 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 32 | with: 33 | install: false 34 | start: npm start 35 | wait-on: 'http://localhost:3000' 36 | command: npm run e2e 37 | -------------------------------------------------------------------------------- /.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 | /cypress/screenshots 9 | /cypress/videos 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .vercel 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hacker News 2 | 3 | [![Test status](https://github.com/wlsf82/hackernews/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/wlsf82/hackernews/actions/workflows/ci-cd.yml) [![hackernews](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/263jf8&style=flat&logo=cypress)](https://dashboard.cypress.io/projects/263jf8/runs) [![This project is using Percy.io for visual regression testing.](https://percy.io/static/images/percy-badge.svg)](https://percy.io/Walmyr-Filho/visual-component-testing) 4 | 5 | From "[The Road to learn React](https://leanpub.com/the-road-to-learn-react)" book. 6 | 7 | Here's the link to the app in "production" https://hackernews-seven.vercel.app. 8 | 9 | > I've also written a similar app in TypeScript, and you can find it through the follwing URL https://github.com/wlsf82/hacker-stories. 10 | 11 | ## Installation 12 | 13 | Run `npm install` to install de project and dev dependencies. 14 | 15 | ## Starting the app 16 | 17 | Run `npm start` to start the application. 18 | 19 | > The app is automatically opened on the default browser on startup. 20 | 21 | ## Running the tests 22 | 23 | Run `npm test` to run the automated tests. 24 | 25 | > The above command starts the app, runs the tests against it, and shuts it down. 26 | 27 | ___ 28 | 29 | Made with ❤️ by [Walmyr](https://walmyr.dev). 30 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | projectId: '263jf8', 5 | retries: { 6 | runMode: 2, 7 | openMode: 0, 8 | }, 9 | e2e: { 10 | setupNodeEvents(on, config) { 11 | require('@cypress/grep/src/plugin')(config) 12 | return config 13 | }, 14 | baseUrl: 'http://localhost:3000', 15 | specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', 16 | supportFile: false, 17 | }, 18 | component: { 19 | devServer: { 20 | framework: 'create-react-app', 21 | bundler: 'webpack', 22 | }, 23 | viewportHight: 1200, 24 | viewportWidth: 1000, 25 | specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}', 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /cypress/e2e/hackernewsStories.cy.js: -------------------------------------------------------------------------------- 1 | describe('Hackernews Stories App', () => { 2 | beforeEach(() => { 3 | cy.intercept( 4 | 'GET', 5 | '**/search**' 6 | ).as('getStories') 7 | 8 | cy.visit('/') 9 | cy.wait('@getStories') 10 | 11 | cy.get('input[type="text"]') 12 | .as('searchField') 13 | .should('be.visible') 14 | .clear() 15 | }) 16 | 17 | it('searches by a term and it returns another 100 results', () => { 18 | cy.get('@searchField') 19 | .type('react{enter}') 20 | 21 | cy.wait('@getStories') 22 | 23 | cy.get('.table-row') 24 | .should('have.length', 100) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /cypress/fixtures/incomplete.json: -------------------------------------------------------------------------------- 1 | { 2 | "list": [ 3 | { 4 | "objectID": "2", 5 | "title": "The Goal", 6 | "author": "Eliyahu M. Goldratt", 7 | "url": "https://example.com/eg" 8 | }, 9 | { 10 | "objectID": "3", 11 | "title": "The Clean Coder", 12 | "author": "Robert C. Martin", 13 | "url": "https://example.com/rm2" 14 | }, 15 | { 16 | "objectID": "4", 17 | "title": "Clean Architecture", 18 | "author": "Robert C. Martin", 19 | "url": "https://example.com/rm3" 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /cypress/fixtures/stories.json: -------------------------------------------------------------------------------- 1 | { 2 | "list": [ 3 | { 4 | "objectID": "0", 5 | "title": "Agile Testing", 6 | "author": "Lisa Crispin & Janet Gregory", 7 | "url": "https://example.com/lc-jg", 8 | "num_comments": 85, 9 | "points": 1000 10 | }, 11 | { 12 | "objectID": "1", 13 | "title": "Clean Code", 14 | "author": "Robert C. Martin", 15 | "url": "https://example.com/rm", 16 | "num_comments": 98, 17 | "points": 1953 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /cypress/support/component.js: -------------------------------------------------------------------------------- 1 | const registerCypressGrep = require('@cypress/grep') 2 | registerCypressGrep() 3 | 4 | import '@percy/cypress' 5 | import { mount } from 'cypress/react' 6 | 7 | Cypress.Commands.add('mount', mount) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackernews", 3 | "version": "0.1.0", 4 | "dependencies": { 5 | "axios": "^1.7.3", 6 | "classnames": "^2.5.1", 7 | "lodash": "^4.17.21", 8 | "prop-types": "^15.8.1", 9 | "react": "^18.3.1", 10 | "react-dom": "^18.3.1", 11 | "react-scripts": "^5.0.1" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "e2e": "cypress run --record --tag 'e2e'", 17 | "e2e:prod": "cypress run --record --tag 'e2e prod' --config baseUrl=https://hackernews-seven.vercel.app", 18 | "ct": "cypress run --component --record --tag 'component' --env grepTags=-@visual", 19 | "cy:open": "cypress open", 20 | "vt": "percy exec -- cypress run --component --record --tag 'component,visual' --env grepTags=@visual" 21 | }, 22 | "browserslist": [ 23 | "last 1 version", 24 | "> 1%", 25 | "not dead" 26 | ], 27 | "devDependencies": { 28 | "@cypress/grep": "^4.1.0", 29 | "@percy/cli": "^1.30.1", 30 | "@percy/cypress": "^3.1.3-beta.0", 31 | "cypress": "^13.15.0", 32 | "start-server-and-test": "^2.0.8" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlsf82/hackernews/7fc7b053afc207f6d421358bf6c9588882825f67/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/App/App.cy.js: -------------------------------------------------------------------------------- 1 | import '../../index.css' 2 | import App from './' 3 | 4 | describe('App', () => { 5 | context('Happy path', () => { 6 | const stories = require('../../../cypress/fixtures/stories') 7 | const incomplete = require('../../../cypress/fixtures/incomplete') 8 | const pageOneResBody = { 9 | hits: [ 10 | stories.list[0], 11 | stories.list[1] 12 | ] 13 | } 14 | const pageTwoResBody = { 15 | hits: [ 16 | incomplete.list[0], 17 | incomplete.list[1], 18 | incomplete.list[2] 19 | ] 20 | } 21 | 22 | beforeEach(() => { 23 | cy.intercept( 24 | 'GET', 25 | '**/search?query=redux&page=0&hitsPerPage=100', 26 | { body: pageOneResBody } 27 | ) 28 | 29 | cy.intercept( 30 | 'GET', 31 | '**/search?query=redux&page=1&hitsPerPage=100', 32 | { body: pageTwoResBody } 33 | ) 34 | 35 | cy.mount() 36 | 37 | cy.get('input[type="text"]') 38 | .should('be.visible') 39 | .blur() 40 | 41 | cy.get('.table-row') 42 | .should('have.length', stories.list.length) 43 | }) 44 | 45 | it('dismisses one item', { tags: '@visual' }, function() { 46 | cy.get('button') 47 | .contains('Dismiss') 48 | .should('be.visible') 49 | .click() 50 | 51 | cy.get('.table-row') 52 | .should('have.length', stories.list.length - 1) 53 | 54 | cy.percySnapshot(`${this.test.parent.title} - ${this.test.title}`) 55 | }) 56 | 57 | it('loads more items', { tags: '@visual' }, function() { 58 | cy.get('button') 59 | .contains('More') 60 | .should('be.visible') 61 | .click() 62 | 63 | cy.get('.table-row') 64 | .should('have.length', stories.list.length + incomplete.list.length) 65 | 66 | cy.percySnapshot(`${this.test.parent.title} - ${this.test.title}`) 67 | }) 68 | }) 69 | 70 | context('Failure path', () => { 71 | it('fallsback on a network failure', { tags: '@visual' }, function() { 72 | cy.intercept( 73 | 'GET', 74 | '**/search**', 75 | { forceNetworkError: true } 76 | ) 77 | 78 | cy.mount() 79 | 80 | cy.get('input[type="text"]') 81 | .should('be.visible') 82 | .blur() 83 | 84 | cy.get('p') 85 | .contains('Something went wrong.') 86 | .should('be.visible') 87 | 88 | cy.percySnapshot(`${this.test.parent.title} - ${this.test.title}`) 89 | }) 90 | 91 | it('fallsback on a server failure', () => { 92 | cy.intercept( 93 | 'GET', 94 | '**/search**', 95 | { statusCode: 500 } 96 | ) 97 | 98 | cy.mount() 99 | 100 | cy.get('p') 101 | .contains('Something went wrong.') 102 | .should('be.visible') 103 | }) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /src/components/App/index.css: -------------------------------------------------------------------------------- 1 | .page { 2 | margin: 20px; 3 | } 4 | 5 | .interactions { 6 | text-align: center; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import axios from 'axios'; 3 | 4 | import './index.css'; 5 | 6 | import { ButtonWithLoading } from '../Loading/ButtonWithLoading'; 7 | import Search from '../Search'; 8 | import Table from '../Table'; 9 | 10 | import { 11 | DEFAULT_QUERY, 12 | DEFAULT_HPP, 13 | PATH_BASE, 14 | PATH_SEARCH, 15 | PARAM_SEARCH, 16 | PARAM_PAGE, 17 | PARAM_HPP, 18 | } from '../../constants'; 19 | 20 | const updateSearchTopStoriesState = (hits, page) => prevState => { 21 | const { searchKey, results } = prevState; 22 | 23 | const oldHits = results && results[searchKey] 24 | ? results[searchKey].hits 25 | : []; 26 | 27 | const updatedHits = [ 28 | ...oldHits, 29 | ...hits, 30 | ]; 31 | 32 | return { 33 | results: { 34 | ...results, 35 | [searchKey]: { hits: updatedHits, page } 36 | }, 37 | isLoading: false, 38 | }; 39 | }; 40 | 41 | class App extends Component { 42 | _isMounted = false; 43 | 44 | constructor(props) { 45 | super(props); 46 | 47 | this.state = { 48 | results: null, 49 | searchKey: '', 50 | searchTerm: DEFAULT_QUERY, 51 | error: null, 52 | isLoading: false, 53 | } 54 | 55 | this.needsToSearchTopStories = this.needsToSearchTopStories.bind(this); 56 | this.fetchSearchTopStories = this.fetchSearchTopStories.bind(this); 57 | this.setSearchTopStories = this.setSearchTopStories.bind(this); 58 | this.onDismiss = this.onDismiss.bind(this); 59 | this.onSearchChange = this.onSearchChange.bind(this); 60 | this.onSearchSubmit = this.onSearchSubmit.bind(this); 61 | } 62 | 63 | needsToSearchTopStories(searchTerm) { 64 | return !this.state.results[searchTerm]; 65 | } 66 | 67 | fetchSearchTopStories(searchTerm, page = 0) { 68 | this.setState({ isLoading: true }); 69 | 70 | axios(`${PATH_BASE}${PATH_SEARCH}?${PARAM_SEARCH}${searchTerm}&${PARAM_PAGE}${page}&${PARAM_HPP}${DEFAULT_HPP}`) 71 | .then(result => this._isMounted && this.setSearchTopStories(result.data)) 72 | .catch(error => this._isMounted && this.setState({ error })); 73 | } 74 | 75 | setSearchTopStories(result) { 76 | const { hits, page } = result; 77 | 78 | this.setState(updateSearchTopStoriesState(hits, page)); 79 | } 80 | 81 | onDismiss(id) { 82 | const { searchKey, results } = this.state; 83 | const { hits, page } = results[searchKey]; 84 | 85 | 86 | const isNotId = item => item.objectID !== id; 87 | const updatedHits = hits.filter(isNotId); 88 | 89 | this.setState({ 90 | results: { 91 | ...this.state.result, 92 | [searchKey]: { hits: updatedHits, page }, 93 | } 94 | }); 95 | } 96 | 97 | onSearchChange(event) { 98 | this.setState({ searchTerm: event.target.value }); 99 | } 100 | 101 | onSearchSubmit(event) { 102 | const { searchTerm } = this.state; 103 | 104 | this.setState({ searchKey: searchTerm }); 105 | 106 | if (this.needsToSearchTopStories(searchTerm)) { 107 | this.fetchSearchTopStories(searchTerm); 108 | } 109 | 110 | event.preventDefault(); 111 | } 112 | 113 | componentDidMount() { 114 | this._isMounted = true; 115 | 116 | const { searchTerm } = this.state; 117 | 118 | this.setState({ searchKey: searchTerm }); 119 | this.fetchSearchTopStories(searchTerm); 120 | } 121 | 122 | componentWillUnmount() { 123 | this._isMounted = false; 124 | } 125 | 126 | render() { 127 | const { 128 | searchTerm, 129 | results, 130 | searchKey, 131 | error, 132 | isLoading, 133 | } = this.state; 134 | 135 | const page = ( 136 | results && 137 | results[searchKey] && 138 | results[searchKey].page 139 | ) || 0; 140 | 141 | const list = ( 142 | results && 143 | results[searchKey] && 144 | results[searchKey].hits 145 | ) || []; 146 | 147 | return ( 148 |
149 |
150 | 155 | Search 156 | 157 |
158 | { error 159 | ?
160 |

Something went wrong.

161 |
162 | : 166 | } 167 | { error 168 | ? undefined 169 | :
170 | this.fetchSearchTopStories(searchKey, page + 1)} 173 | > 174 | More 175 | 176 |
177 | } 178 | 179 | ); 180 | } 181 | } 182 | 183 | export default App; 184 | -------------------------------------------------------------------------------- /src/components/Button/Button.cy.js: -------------------------------------------------------------------------------- 1 | import '../../index.css' 2 | import Button from './' 3 | 4 | describe('Button component', () => { 5 | let defaultProps 6 | 7 | beforeEach(() => { 8 | defaultProps = { 9 | onClick: cy.stub().as('onClickHandler') 10 | } 11 | }) 12 | 13 | it('renders as an inline button', () => { 14 | cy.mount( 15 | 18 | ) 19 | 20 | cy.get('button') 21 | .should('have.class', 'button-inline') 22 | .and('have.text', 'Dismiss') 23 | .and('be.visible') 24 | }) 25 | 26 | it('triggers onClick event', () => { 27 | cy.mount( 28 | 31 | ) 32 | 33 | cy.get('button') 34 | .contains('Click here') 35 | .click() 36 | 37 | cy.get('@onClickHandler') 38 | .should('have.been.calledOnce') 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/components/Button/index.css: -------------------------------------------------------------------------------- 1 | .button-inline { 2 | border-width: 0; 3 | background: transparent; 4 | color: inherit; 5 | text-align: inherit; 6 | -webkit-font-smoothing: inherit; 7 | padding: 0; 8 | font-size: inherit; 9 | cursor: pointer; 10 | } 11 | 12 | .button-active { 13 | border-radius: 0; 14 | border-bottom: 1px solid #38BB6C; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './index.css'; 5 | 6 | const Button = ({onClick, className, children}) => 7 | 14 | 15 | Button.propTypes = { 16 | onClick: PropTypes.func.isRequired, 17 | className: PropTypes.string, 18 | children: PropTypes.node.isRequired, 19 | }; 20 | 21 | Button.defaultProps = { 22 | className: '', 23 | }; 24 | 25 | export default Button; 26 | -------------------------------------------------------------------------------- /src/components/Loading/ButtonWithLoading.js: -------------------------------------------------------------------------------- 1 | import Button from '../Button'; 2 | import { withLoading } from './withLoading'; 3 | 4 | const ButtonWithLoading = withLoading(Button); 5 | 6 | export { ButtonWithLoading }; 7 | -------------------------------------------------------------------------------- /src/components/Loading/Loading.cy.js: -------------------------------------------------------------------------------- 1 | import '../../index.css' 2 | import Loading from './' 3 | 4 | describe('Loading component', () => { 5 | it('renders', { tags: '@visual' }, function() { 6 | cy.mount() 7 | 8 | cy.get('div') 9 | .contains('Loading ...') 10 | .should('be.visible') 11 | 12 | cy.percySnapshot(`${this.test.parent.title} - ${this.test.title}`) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/components/Loading/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loading = () =>
Loading ...
4 | 5 | export default Loading; 6 | -------------------------------------------------------------------------------- /src/components/Loading/withLoading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Loading from './index'; 4 | 5 | const withLoading = Component => ({ isLoading, ...rest }) => isLoading 6 | ? 7 | : 8 | 9 | export { withLoading }; 10 | -------------------------------------------------------------------------------- /src/components/Search/Search.cy.js: -------------------------------------------------------------------------------- 1 | import '../../index.css' 2 | import Search from './' 3 | 4 | describe('Search component', () => { 5 | let defaultProps 6 | 7 | beforeEach(() => { 8 | defaultProps = { 9 | onChange: cy.stub(), 10 | onSubmit: cy.stub() 11 | } 12 | }) 13 | 14 | it('renders with a Search button', { tags: '@visual' }, function() { 15 | cy.mount(Search) 16 | 17 | cy.get('input[type="text"]') 18 | .should('be.visible') 19 | .blur() 20 | 21 | cy.get('input[type="text"]') 22 | .should('be.visible') 23 | .and('have.value', '') 24 | cy.get('button') 25 | .contains('Search') 26 | .should('be.visible') 27 | 28 | cy.percySnapshot(`${this.test.parent.title} - ${this.test.title}`) 29 | }) 30 | 31 | it('renders with value and Search button', () => { 32 | const props = { 33 | ...defaultProps, 34 | value: 'cypress.io' 35 | } 36 | 37 | cy.mount(Search) 38 | 39 | cy.get('input[type="text"]') 40 | .should('be.visible') 41 | .blur() 42 | 43 | cy.get('input[type="text"]') 44 | .should('be.visible') 45 | .and('have.value', props.value) 46 | cy.get('button') 47 | .contains('Search') 48 | .should('be.visible') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/components/Search/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Search extends Component { 5 | componentDidMount() { 6 | if (this.input) { 7 | this.input.focus(); 8 | } 9 | } 10 | 11 | render() { 12 | const { 13 | value, 14 | onChange, 15 | onSubmit, 16 | children, 17 | } = this.props; 18 | 19 | return ( 20 |
21 | { this.input = node; }} 27 | /> 28 | 31 | 32 | ); 33 | } 34 | } 35 | 36 | Search.propTypes = { 37 | value: PropTypes.string, 38 | onChange: PropTypes.func.isRequired, 39 | onSubmit: PropTypes.func.isRequired, 40 | children: PropTypes.node.isRequired, 41 | }; 42 | 43 | export default Search; 44 | -------------------------------------------------------------------------------- /src/components/Sort/Sort.cy.js: -------------------------------------------------------------------------------- 1 | import '../../index.css' 2 | import Sort from './' 3 | 4 | describe('Sort component', () => { 5 | it('renders not active', () => { 6 | const props = { 7 | sortKey: 'foo', 8 | activeSortKey: 'bar' 9 | } 10 | 11 | cy.mount(I'm not active) 12 | 13 | cy.get('button') 14 | .contains('I\'m not active') 15 | .should('not.have.class','button-active') 16 | .and('be.visible') 17 | }) 18 | 19 | it('renders active', () => { 20 | const props = { 21 | sortKey: 'baz', 22 | activeSortKey: 'baz' 23 | } 24 | 25 | cy.mount(Yay! I'm active) 26 | 27 | cy.get('button') 28 | .contains('Yay! I\'m active') 29 | .should('have.class','button-active') 30 | .and('be.visible') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/components/Sort/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import Button from '../Button'; 5 | 6 | const Sort = ({ 7 | sortKey, 8 | activeSortKey, 9 | onSort, 10 | children 11 | }) => { 12 | const sortClass = classNames( 13 | 'button-inline', 14 | { 'button-active': sortKey === activeSortKey } 15 | ); 16 | 17 | return ( 18 | 24 | ) 25 | } 26 | 27 | export default Sort; 28 | -------------------------------------------------------------------------------- /src/components/Table/Table.cy.js: -------------------------------------------------------------------------------- 1 | import '../../index.css' 2 | import Table from './' 3 | 4 | describe('Table component', () => { 5 | let defaultProps 6 | 7 | beforeEach(() => { 8 | defaultProps = { 9 | onClick: cy.stub(), 10 | onDismiss: cy.stub() 11 | } 12 | }) 13 | 14 | it('renders empty', { tags: '@visual' }, function() { 15 | const props = { 16 | ...defaultProps, 17 | list: [] 18 | } 19 | 20 | cy.mount(
) 21 | 22 | cy.get('.table-row') 23 | .should('not.exist') 24 | 25 | cy.percySnapshot(`${this.test.parent.title} - ${this.test.title}`) 26 | }) 27 | 28 | context('Not empty', () => { 29 | let props 30 | 31 | beforeEach(() => { 32 | props = { 33 | ...defaultProps, 34 | ...require('../../../cypress/fixtures/stories') 35 | } 36 | 37 | cy.mount(
) 38 | }) 39 | 40 | it('renders with some items', () => { 41 | cy.get('.table-row') 42 | .should('have.length', props.list.length) 43 | }) 44 | 45 | it('orders by points', { tags: '@visual' }, function() { 46 | cy.get('.table-row') 47 | .first() 48 | .should('contain', props.list[0].points) 49 | .and('be.visible') 50 | cy.get('.table-row') 51 | .last() 52 | .should('contain', props.list[1].points) 53 | .and('be.visible') 54 | 55 | cy.get('span button') 56 | .contains('Points') 57 | .as('pointsHeader') 58 | .should('not.have.class', 'button-active') 59 | .and('be.visible') 60 | .click() 61 | .should('have.class', 'button-active') 62 | 63 | cy.get('.table-row') 64 | .first() 65 | .should('contain', props.list[1].points) 66 | cy.get('.table-row') 67 | .last() 68 | .should('contain', props.list[0].points) 69 | 70 | cy.percySnapshot(`${this.test.parent.parent.title} - ${this.test.parent.title} - ${this.test.title} - order desc`) 71 | 72 | cy.get('@pointsHeader') 73 | .click() 74 | 75 | cy.get('.table-row') 76 | .first() 77 | .should('contain', props.list[0].points) 78 | cy.get('.table-row') 79 | .last() 80 | .should('contain', props.list[1].points) 81 | 82 | cy.percySnapshot(`${this.test.parent.parent.title} - ${this.test.parent.title} - ${this.test.title} - order asc`) 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /src/components/Table/index.css: -------------------------------------------------------------------------------- 1 | .table { 2 | margin: 20px 0; 3 | } 4 | 5 | .table-header { 6 | display: flex; 7 | line-height: 24px; 8 | font-size: 16px; 9 | padding: 0 10px; 10 | justify-content: space-between; 11 | } 12 | 13 | .table-empty { 14 | margin: 200px; 15 | text-align: center; 16 | font-size: 16px; 17 | } 18 | 19 | .table-row { 20 | display: flex; 21 | line-height: 24px; 22 | white-space: nowrap; 23 | margin: 10px 0; 24 | padding: 10px; 25 | background: #fff; 26 | border: 1px solid #e3e3e3; 27 | } 28 | 29 | .table-header > span { 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | padding: 0 5px; 33 | } 34 | 35 | .table-row > span { 36 | overflow: hidden; 37 | text-overflow: ellipsis; 38 | padding: 0 5px; 39 | } 40 | 41 | .table button:hover { 42 | font-weight: bold; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Table/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { sortBy } from 'lodash'; 4 | 5 | import './index.css'; 6 | 7 | import Button from '../Button'; 8 | import Sort from '../Sort'; 9 | 10 | const SORTS = { 11 | NONE: list => list, 12 | TITLE: list => sortBy(list, 'title'), 13 | AUTHOR: list => sortBy(list, 'author'), 14 | COMMENTS: list => sortBy(list, 'num_comments').reverse(), 15 | POINTS: list => sortBy(list, 'points').reverse(), 16 | }; 17 | 18 | class Table extends Component { 19 | constructor(props) { 20 | super(props); 21 | 22 | this.state = { 23 | sortKey: 'NONE', 24 | isSortReverse: false, 25 | }; 26 | 27 | this.onSort = this.onSort.bind(this); 28 | } 29 | 30 | onSort(sortKey) { 31 | const isSortReverse = this.state.sortKey === sortKey && !this.state.isSortReverse; 32 | 33 | this.setState({ sortKey, isSortReverse }); 34 | } 35 | 36 | render() { 37 | const { 38 | list, 39 | onDismiss, 40 | } = this.props; 41 | 42 | const { 43 | sortKey, 44 | isSortReverse, 45 | } = this.state; 46 | 47 | const sortedList = SORTS[sortKey](list); 48 | const reverseSortedList = isSortReverse 49 | ? sortedList.reverse() 50 | : sortedList; 51 | 52 | return ( 53 |
54 |
55 | 56 | 61 | Title 62 | 63 | 64 | 65 | 70 | Author 71 | 72 | 73 | 74 | 79 | Comments 80 | 81 | 82 | 83 | 88 | Points 89 | 90 | 91 | 92 | Archive 93 | 94 |
95 | {reverseSortedList.map(item => 96 |
97 | 98 | {item.title} 99 | 100 | {item.author} 101 | {item.num_comments} 102 | {item.points} 103 | 104 | 110 | 111 |
112 | )} 113 |
114 | ); 115 | } 116 | } 117 | 118 | Table.propTypes={ 119 | list: PropTypes.arrayOf( 120 | PropTypes.shape({ 121 | objectID: PropTypes.string.isRequired, 122 | author: PropTypes.string, 123 | url: PropTypes.string, 124 | num_comments: PropTypes.number, 125 | points: PropTypes.number, 126 | }) 127 | ).isRequired, 128 | onDismiss: PropTypes.func.isRequired, 129 | }; 130 | 131 | export default Table; 132 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_QUERY = 'redux'; 2 | export const DEFAULT_HPP='100'; 3 | export const PATH_BASE = 'https://hn.algolia.com/api/v1'; 4 | export const PATH_SEARCH = '/search'; 5 | export const PARAM_SEARCH = 'query='; 6 | export const PARAM_PAGE='page='; 7 | export const PARAM_HPP='hitsPerPage='; 8 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #222; 3 | background: #f4f4f4; 4 | font: 400 14px CoreSans, Arial, sans-serif; 5 | } 6 | 7 | a { 8 | color: #222; 9 | } 10 | 11 | a:hover { 12 | text-decoration: underline; 13 | } 14 | 15 | ul, li { 16 | list-style: none; 17 | padding: 0; 18 | margin: 0; 19 | } 20 | 21 | input { 22 | padding: 10px; 23 | border-radius: 5px; 24 | outline: none; 25 | margin-right: 10px; 26 | border: 1px solid #ddd; 27 | } 28 | 29 | button { 30 | padding: 10px; 31 | border-radius: 5px; 32 | border: 1px solid #ddd; 33 | background: transparent; 34 | color: #7D5956; 35 | cursor: pointer; 36 | } 37 | 38 | button:hover { 39 | color: #222; 40 | } 41 | 42 | *:focus { 43 | outline: none; 44 | } 45 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import './index.css'; 5 | 6 | import App from './components/App'; 7 | import registerServiceWorker from './registerServiceWorker'; 8 | 9 | ReactDOM.render(, document.getElementById('root')); 10 | registerServiceWorker(); 11 | 12 | if(module.hot) { 13 | module.hot.accept(); 14 | } 15 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "public": true, 4 | "github": { 5 | "enabled": false 6 | } 7 | } 8 | --------------------------------------------------------------------------------