├── .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 | [](https://github.com/wlsf82/hackernews/actions/workflows/ci-cd.yml) [](https://dashboard.cypress.io/projects/263jf8/runs) [](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 |
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 |
--------------------------------------------------------------------------------