├── .babelrc ├── .gitattributes ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .stylelintrc.json ├── .yarnrc.yml ├── README.md ├── app ├── components │ ├── Clickable │ │ ├── index.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ └── index.test.js │ ├── ErrorState │ │ ├── index.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ └── index.test.js │ ├── If │ │ ├── index.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ └── index.test.js │ ├── Meta │ │ ├── index.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ └── index.test.js │ ├── Recommended │ │ ├── index.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ └── index.test.js │ ├── RepoList │ │ ├── index.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ └── index.test.js │ ├── Text │ │ ├── index.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ └── index.test.js │ ├── Title │ │ ├── index.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ └── index.test.js │ └── styled │ │ ├── index.js │ │ ├── repos.js │ │ └── tests │ │ ├── __snapshots__ │ │ └── index.test.js.snap │ │ └── index.test.js ├── configureStore.js ├── containers │ ├── Info │ │ ├── index.js │ │ ├── reducer.js │ │ ├── saga.js │ │ ├── selectors.js │ │ └── tests │ │ │ ├── __snapshots__ │ │ │ └── index.test.js.snap │ │ │ ├── index.test.js │ │ │ ├── reducer.test.js │ │ │ ├── saga.test.js │ │ │ └── selectors.test.js │ └── Repos │ │ ├── index.js │ │ ├── reducer.js │ │ ├── saga.js │ │ ├── selectors.js │ │ └── tests │ │ ├── __snapshots__ │ │ └── index.test.js.snap │ │ ├── index.test.js │ │ ├── reducer.test.js │ │ ├── saga.test.js │ │ └── selectors.test.js ├── global-styles.js ├── i18n.js ├── images │ ├── favicon.ico │ └── ws-icon.png ├── lang │ └── en.json ├── reducers.js ├── services │ ├── info.js │ ├── repoApi.js │ ├── root.js │ └── tests │ │ ├── info.test.js │ │ ├── repoApi.test.js │ │ └── root.test.js ├── tests │ └── i18n.test.js ├── themes │ ├── colors.js │ ├── fonts.js │ ├── index.js │ ├── media.js │ ├── styles.js │ └── tests │ │ ├── colors.test.js │ │ ├── fonts.test.js │ │ ├── media.test.js │ │ └── styles.test.js └── utils │ ├── apiUtils.js │ ├── checkStore.js │ ├── constants.js │ ├── index.js │ ├── injectSaga.js │ ├── reducer.js │ ├── sagaInjectors.js │ ├── testUtils.js │ └── tests │ ├── checkStore.test.js │ ├── index.test.js │ ├── injectSaga.test.js │ └── sagaInjectors.test.js ├── config └── jest │ ├── cssTransform.js │ └── image.js ├── environments ├── .env.development ├── .env.production └── .env.qa ├── eslint.config.mjs ├── jest.config.js ├── jest.setup.js ├── jsconfig.json ├── next.config.js ├── package.json ├── pages ├── _app.js ├── _document.js ├── index.js └── info │ └── [name].js ├── polyfills.js ├── server └── index.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "plugins": [["babel-plugin-styled-components", { "ssr": true, "displayName": true, "preprocess": false }]], 5 | "presets": ["next/babel"] 6 | }, 7 | "production": { 8 | "plugins": [["babel-plugin-styled-components", { "ssr": true, "displayName": true, "preprocess": false }]], 9 | "presets": [ 10 | [ 11 | "next/babel", 12 | { 13 | "preset-env": { 14 | "targets": { 15 | "browsers": [">0.03%"] 16 | } 17 | } 18 | } 19 | ] 20 | ] 21 | }, 22 | "test": { 23 | "presets": [ 24 | [ 25 | "@babel/preset-env", 26 | { 27 | "targets": { 28 | "node": "current" 29 | } 30 | } 31 | ], 32 | "@babel/preset-react", 33 | "@babel/preset-typescript" 34 | ] 35 | } 36 | }, 37 | "plugins": [["babel-plugin-styled-components", { "ssr": true, "displayName": true, "preprocess": false }]] 38 | } 39 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # From https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text eol=lf 23 | *.coffee text 24 | *.json text 25 | *.htm text 26 | *.html text 27 | *.xml text 28 | *.svg text 29 | *.txt text 30 | *.ini text 31 | *.inc text 32 | *.pl text 33 | *.rb text 34 | *.py text 35 | *.scm text 36 | *.sql text 37 | *.sh text 38 | *.bat text 39 | 40 | # templates 41 | *.ejs text 42 | *.hbt text 43 | *.jade text 44 | *.haml text 45 | *.hbs text 46 | *.dot text 47 | *.tmpl text 48 | *.phtml text 49 | 50 | # server config 51 | .htaccess text 52 | .nginx.conf text 53 | 54 | # git config 55 | .gitattributes text 56 | .gitignore text 57 | .gitconfig text 58 | 59 | # code analysis config 60 | .jshintrc text 61 | .jscsrc text 62 | .jshintignore text 63 | .csslintrc text 64 | 65 | # misc config 66 | *.yaml text 67 | *.yml text 68 | .editorconfig text 69 | 70 | # build config 71 | *.npmignore text 72 | *.bowerrc text 73 | 74 | # Heroku 75 | Procfile text 76 | .slugignore text 77 | 78 | # Documentation 79 | *.md text 80 | LICENSE text 81 | AUTHORS text 82 | 83 | 84 | # 85 | ## These files are binary and should be left untouched 86 | # 87 | 88 | # (binary is a macro for -text -diff) 89 | *.png binary 90 | *.jpg binary 91 | *.jpeg binary 92 | *.gif binary 93 | *.ico binary 94 | *.mov binary 95 | *.mp4 binary 96 | *.mp3 binary 97 | *.flv binary 98 | *.fla binary 99 | *.swf binary 100 | *.gz binary 101 | *.zip binary 102 | *.7z binary 103 | *.ttf binary 104 | *.eot binary 105 | *.woff binary 106 | *.pyc binary 107 | *.pdf binary 108 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Next.js Template CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [20.x] 15 | env: 16 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 17 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 18 | AWS_REGION: ${{ secrets.AWS_REGION }} 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Get branch name 29 | id: vars 30 | run: echo ::set-output name=short_ref::${GITHUB_REF#refs/*/} 31 | 32 | - name: Set env.ENV_NAME 33 | run: | 34 | if [[ ${{steps.vars.outputs.short_ref}} == 'master' ]]; then 35 | echo "ENV_NAME=prod" >> "$GITHUB_ENV" 36 | else 37 | echo "ENV_NAME=dev" >> "$GITHUB_ENV" 38 | fi 39 | 40 | - name: Install dependencies 41 | run: yarn 42 | 43 | - name: Build 44 | run: yarn build:${{ env.ENV_NAME }} 45 | 46 | - name: Lint 47 | run: yarn lint 48 | 49 | - name: Deploy to S3 50 | uses: jakejarvis/s3-sync-action@master 51 | with: 52 | args: --acl public-read --follow-symlinks 53 | env: 54 | AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}-${{ env.ENV_NAME }} 55 | SOURCE_DIR: './out/' 56 | 57 | - name: Invalidate CloudFront 58 | if: github.ref == 'refs/heads/master' 59 | uses: chetan/invalidate-cloudfront-action@master 60 | env: 61 | DISTRIBUTION: ${{ secrets.DISTRIBUTION_ID }} 62 | PATHS: '/*' 63 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Next.js Template CI 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build-and-test: 9 | name: Build & Test 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [20.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'yarn' 23 | 24 | - name: Install dependencies 25 | run: yarn 26 | 27 | - name: Lint 28 | run: yarn lint 29 | 30 | - name: Test and generate coverage report 31 | uses: artiomtr/jest-coverage-report-action@v2.3.0 32 | with: 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | threshold: 80 35 | package-manager: yarn 36 | 37 | - name: Build 38 | run: yarn build:dev 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | /idea 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # .yarn 17 | .yarn/* 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env.local 33 | .env.development.local 34 | .env.test.local 35 | .env.production.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # server 41 | server/server.crt 42 | server/server.key 43 | 44 | #editors 45 | .vscode/ 46 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | internals/generators/ 4 | internals/scripts/ 5 | package-lock.json 6 | yarn.lock 7 | package.json 8 | react-template.svg 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "trailingComma": "none", 7 | "indent": 2 8 | } 9 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "customSyntax": "postcss-styled-syntax", 3 | "extends": [ 4 | "stylelint-config-recommended" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": ["**/*.scss"], 9 | "customSyntax": "postcss-scss", 10 | "rules": { 11 | "at-rule-no-unknown": null 12 | } 13 | }, 14 | { 15 | "files": ["**/*.{js,ts}"], 16 | "rules": { 17 | "at-rule-no-unknown": null 18 | } 19 | } 20 | ], 21 | "rules": { 22 | "at-rule-no-unknown": null 23 | } 24 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |

6 |

Next.js Template 7 |

8 |

9 | 10 |

11 | An enterprise Next.js template application showcasing - Testing strategies, Global state management, Custom enviornments, a network layer, component library integration, localization, Image loading, Custom App, Custom document, IE 11 compatibility and Continuous integration & deployment. 12 |

13 | 14 | --- 15 | 16 |

17 |

18 | Expert teams of digital product strategists, developers, and designers. 19 |

20 |

21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | --- 32 | 33 | We’re always looking for people who value their work, so come and join us. We are hiring! 34 | 35 |
36 | 37 | ### Out of the box support 38 | 39 | - Global state management using `redux` 40 | - Side Effects using `redux-saga` 41 | - API calls using `api-sauce` 42 | - Styling using emotion 43 | - Reusing components from Ant design 44 | - Translations using `react-intl` 45 | - Custom enviornments using `emv-cmd` 46 | - Image loading using `next-images` 47 | - IE 11 compatible 48 | 49 | ## Global state management using reduxSauce 50 | 51 | - Global state management using [Redux Sauce](https://github.com/infinitered/reduxsauce) 52 | 53 | Take a look at the following files 54 | 55 | - [app/store/reducers/app.js](app/store/reducers/app.js) 56 | 57 | - Computing and getting state from the redux store using [Reselect](https://github.com/reduxjs/reselect) 58 | 59 | Take a look at the following files 60 | 61 | - [app/store/selectors/app.js](app/store/selectors/app.js) 62 | 63 | ## Implementing a Redux middleware using redux-sagas 64 | 65 | - Side effects using [Redux Saga](https://github.com/redux-saga/redux-saga) 66 | 67 | Take a look at the following files 68 | 69 | - [app/utils/injectSaga.js](app/utils/injectSaga.js) 70 | - [app/utils/sagaInjectors.js](app/utils/sagaInjectors.js) 71 | - [app/store/sagas/app.js](app/containers/HomeContainer/saga.js) 72 | - [pages/index.js](app/containers/HomeContainer/index.js) 73 | 74 | ## Network requests using apisauce 75 | 76 | - API calls using [Api Sauce](https://github.com/infinitered/apisauce/) 77 | 78 | Take a look at the following files 79 | 80 | - [app/utils/apiUtils.js](app/utils/apiUtils.js) 81 | - [app/services/repoApi.js](app/services/repoApi.js) 82 | - [pages/index.js](pages/index.js) 83 | 84 | ## Styling using emotion 85 | 86 | - Styling components using [Emotion](https://emotion.sh/) 87 | 88 | Take a look at the following files 89 | 90 | - [app/components/Text/index.js](app/components/Text/index.js) 91 | - [pages/index.js](pages/index.js) 92 | - [pages/\_document.js](pages/_document.js) 93 | 94 | ## Using antd as the component library 95 | 96 | - Reusing components from [Ant design](https://ant.design) 97 | 98 | Take a look at the following files 99 | 100 | - [pages/index.js](pages/index.js) 101 | 102 | ## Localization using react-intl 103 | 104 | - Translations using [React Intl](https://github.com/formatjs/react-intl) 105 | 106 | Take a look at the following files 107 | 108 | - [app/lang/en.json](app/lang/en.json) 109 | - [app/i18n](app/i18n.js) 110 | - [pages/\_app.js](pages/_app.js) 111 | 112 | ## Custom enviornments using emv-cmd 113 | 114 | - Custom enviornments using env-cmd for more flexibilty [env-cmd](https://www.npmjs.com/package/env-cmd) 115 | 116 | Take a look at the following files 117 | 118 | - [environments/env.development](environments/env.development) 119 | - [environments/env.production](environments/env.production) 120 | - [environments/env.qa](environments/env.qa) 121 | - [package.json](package.json) 122 | 123 | Note:- 124 | 125 | 1. To avoid confusion & conflicts, Please use custom enviornments only instead of next.js default enviornment setup 126 | 127 | 2. To include env variables on the client side just add NEXT_PUBLIC before the enviornment variable name 128 | Example : `NEXT_PUBLIC_SAMPLE_VARIABLE: some_value`. 129 | 130 | Example usage for running dev server with .env.development `env-cmd -f environments/.env.development next dev` 131 | 132 | ## Implementing CI/CD pipelines using Github Actions 133 | 134 | - CI/CD using Github Actions. 135 | The CI pipeline has the following phases 136 | 137 | - Checkout 138 | - Install dependencies 139 | - Lint 140 | - Test 141 | - Build 142 | 143 | The CD pipeline has the following phases 144 | 145 | - Checkout 146 | - Install dependencies 147 | - Build 148 | - Deploy 149 | 150 | Take a look at the following files 151 | 152 | - [.github/workflows/ci.yml](.github/workflows/ci.yml) 153 | - [.github/workflows/cd.yml](.github/workflows/cd.yml) 154 | 155 | ## Testing using @testing-library/react 156 | 157 | - Testing is done using the @testing-library/react. 158 | 159 | Take a look at the following files 160 | 161 | - [jest.config.js](jest.config.js) 162 | - [jest.setup.js](jest.setup.js) 163 | - [app/components/Text/tests](app/components/Text/tests) 164 | - [app/services/tests/repoApi.test.js](app/services/tests/repoApi.test.js) 165 | 166 | ## Development 167 | 168 | ### Start server 169 | 170 | - **Development:** `yarn start:dev` 171 | 172 | - **Production:** `yarn start:prod` 173 | 174 | ### Build project (SSG) 175 | 176 | - **Development:** `yarn build:dev` 177 | 178 | - **Production:** `yarn build:prod` 179 | 180 | ### Start Custom server 181 | 182 | - `yarn custom:dev` 183 | 184 | ## Misc 185 | 186 | ### Aliasing 187 | 188 | - @app -> app/ 189 | - @components -> app/components/ 190 | - @services -> app/services/ 191 | - @utils -> app/utils/ 192 | - @themes -> app/themes/ 193 | - @store -> app/store/ 194 | - @images -> app/images/ 195 | 196 | Take a look at the following files 197 | 198 | - [next.config.js](next.config.js) 199 | 200 | ### Index page 201 | 202 | - [pages/index.js](pages/index.js) 203 | 204 | ### Custom document 205 | 206 | - [pages/\_document.js](pages/_document.js) 207 | 208 | ### Custom app 209 | 210 | - [pages/\_app.js](pages/_app.js) 211 | -------------------------------------------------------------------------------- /app/components/Clickable/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Clickable 4 | * 5 | */ 6 | 7 | import React from 'react'; 8 | import PropTypes from 'prop-types'; 9 | import styled from '@emotion/styled'; 10 | import T from '@components/Text'; 11 | 12 | const StyledClickable = styled.div` 13 | color: #1890ff; 14 | &:hover { 15 | cursor: pointer; 16 | } 17 | `; 18 | 19 | /** 20 | * A component that can be clicked 21 | * @param {function} onClick - The function to call when the component is clicked 22 | * @param {string} textId - The id of the text to display 23 | */ 24 | function Clickable({ onClick, textId }) { 25 | return ( 26 | 27 | {textId && } 28 | 29 | ); 30 | } 31 | 32 | Clickable.propTypes = { 33 | onClick: PropTypes.func.isRequired, 34 | textId: PropTypes.string.isRequired 35 | }; 36 | 37 | export default Clickable; 38 | -------------------------------------------------------------------------------- /app/components/Clickable/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` component tests should render and match the snapshot 1`] = ` 4 | .emotion-0 { 5 | color: #1890ff; 6 | } 7 | 8 | .emotion-0:hover { 9 | cursor: pointer; 10 | } 11 | 12 | 13 |
14 |
18 |
19 | 20 | `; 21 | -------------------------------------------------------------------------------- /app/components/Clickable/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for Clickable 4 | * 5 | */ 6 | 7 | import React from 'react' 8 | import { fireEvent } from '@testing-library/dom' 9 | import { renderProvider } from '@utils/testUtils' 10 | import Clickable from '../index' 11 | 12 | describe(' component tests', () => { 13 | it('should render and match the snapshot', () => { 14 | const { baseElement } = renderProvider() 15 | expect(baseElement).toMatchSnapshot() 16 | }) 17 | 18 | it('should contain 1 Clickable component', () => { 19 | const { getAllByTestId } = renderProvider() 20 | expect(getAllByTestId('clickable').length).toBe(1) 21 | }) 22 | 23 | it('should contain render the text according to the textId', () => { 24 | const { getAllByText } = renderProvider() 25 | expect(getAllByText(/Repository List/).length).toBe(1) 26 | }) 27 | 28 | it('should call the prop onClick when the clickable component is clicked', () => { 29 | const clickSpy = jest.fn() 30 | const { getAllByText, queryByText } = renderProvider( 31 | 32 | ) 33 | expect(getAllByText(/Repository List/).length).toBe(1) 34 | fireEvent.click(queryByText(/Repository List/)) 35 | expect(clickSpy).toHaveBeenCalled() 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /app/components/ErrorState/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * ErrorState 4 | * 5 | */ 6 | 7 | import React from 'react'; 8 | import get from 'lodash/get'; 9 | import { compose } from 'redux'; 10 | import PropTypes from 'prop-types'; 11 | import { injectIntl } from 'react-intl'; 12 | import T from '@components/Text'; 13 | import { CustomCard } from '../styled/repos'; 14 | 15 | const ErrorState = (props) => { 16 | const { intl, reposError, loading, reposData } = props; 17 | const getRepoError = () => { 18 | if (reposError) { 19 | return reposError; 20 | } else if (!get(reposData, 'totalCount', 0)) { 21 | return 'respo_search_default'; 22 | } 23 | return null; 24 | }; 25 | 26 | const renderErrorCard = (repoError) => { 27 | return ( 28 | !loading && 29 | repoError && ( 30 | 35 | 36 | 37 | ) 38 | ); 39 | }; 40 | 41 | const repoError = getRepoError(); 42 | return renderErrorCard(repoError); 43 | }; 44 | 45 | ErrorState.propTypes = { 46 | intl: PropTypes.any, 47 | loading: PropTypes.bool.isRequired, 48 | reposData: PropTypes.arrayOf( 49 | PropTypes.shape({ 50 | totalCount: PropTypes.number, 51 | incompleteResults: PropTypes.bool, 52 | items: PropTypes.array 53 | }) 54 | ), 55 | reposError: PropTypes.object, 56 | repoName: PropTypes.string, 57 | recommendations: PropTypes.arrayOf( 58 | PropTypes.shape({ id: PropTypes.number.isRequired, name: PropTypes.string.isRequired }) 59 | ) 60 | }; 61 | 62 | export default compose(injectIntl)(ErrorState); 63 | -------------------------------------------------------------------------------- /app/components/ErrorState/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render and match the snapshot 1`] = ` 4 | .emotion-0.emotion-0 { 5 | margin: 20px 0; 6 | color: grey; 7 | color: grey; 8 | } 9 | 10 | .emotion-2 { 11 | white-space: pre-line; 12 | } 13 | 14 | 15 |
16 |
21 |
24 |
27 |
30 | Repository List 31 |
32 |
33 |
34 |
37 | 41 | Search for a repository by entering it's name in the search box 42 | 43 |
44 |
45 |
46 | 47 | `; 48 | -------------------------------------------------------------------------------- /app/components/ErrorState/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for ErrorState 4 | * 5 | */ 6 | 7 | import React from 'react' 8 | // import { fireEvent } from '@testing-library/dom' 9 | import { renderProvider } from '@utils/testUtils' 10 | import ErrorState from '../index' 11 | 12 | describe('', () => { 13 | it('should render and match the snapshot', () => { 14 | const { baseElement } = renderProvider() 15 | expect(baseElement).toMatchSnapshot() 16 | }) 17 | 18 | it('should contain 1 ErrorState component', () => { 19 | const { getAllByTestId } = renderProvider( 20 | 21 | ) 22 | expect(getAllByTestId('error-state').length).toBe(1) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /app/components/If/index.js: -------------------------------------------------------------------------------- 1 | import Proptypes from 'prop-types'; 2 | const If = (props) => (props.condition ? props.children : props.otherwise); 3 | If.propsTypes = { 4 | condition: Proptypes.bool, 5 | otherwise: Proptypes.oneOfType([Proptypes.arrayOf(Proptypes.node), Proptypes.node]), 6 | children: Proptypes.oneOfType([Proptypes.arrayOf(Proptypes.node), Proptypes.node]) 7 | }; 8 | If.defaultProps = { 9 | otherwise: null 10 | }; 11 | export default If; 12 | -------------------------------------------------------------------------------- /app/components/If/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Should renderProvider and match the snapshot 1`] = ` 4 | 5 |
6 | 7 | `; 8 | -------------------------------------------------------------------------------- /app/components/If/tests/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import If from '../index' 3 | import { renderProvider } from '@utils/testUtils' 4 | 5 | describe('', () => { 6 | it('Should renderProvider and match the snapshot', () => { 7 | const { baseElement } = renderProvider() 8 | expect(baseElement).toMatchSnapshot() 9 | }) 10 | 11 | it('should enter the true branch', () => { 12 | const { container } = renderProvider( 13 | }> 14 | 15 | 16 | ) 17 | expect(container).toMatchInlineSnapshot(` 18 |
19 | 20 |
21 | `) 22 | }) 23 | 24 | it('should enter the false branch', () => { 25 | const { container } = renderProvider( 26 | }> 27 | 28 | 29 | ) 30 | expect(container).toMatchInlineSnapshot(` 31 |
32 |
33 |
34 | `) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /app/components/Meta/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Meta 4 | * 5 | */ 6 | 7 | import Head from 'next/head'; 8 | import React, { memo } from 'react'; 9 | import PropTypes from 'prop-types'; 10 | import { useIntl } from 'react-intl'; 11 | import favicon from '@images/favicon.ico'; 12 | 13 | /** 14 | * The Meta component 15 | * @param {string} title - The title of the page 16 | * @param {string} description - The description of the page 17 | * @param {boolean} useTranslation - Whether to use translation for the title and description 18 | */ 19 | function Meta({ title, description, useTranslation }) { 20 | const intl = useIntl(); 21 | 22 | return ( 23 | 24 | {useTranslation ? intl.formatMessage({ id: title }) : title} 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | Meta.propTypes = { 33 | title: PropTypes.string, 34 | description: PropTypes.string, 35 | useTranslation: PropTypes.bool 36 | }; 37 | 38 | export default memo(Meta); 39 | -------------------------------------------------------------------------------- /app/components/Meta/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render and match the snapshot 1`] = ` 4 | 5 |
6 | 7 | `; 8 | -------------------------------------------------------------------------------- /app/components/Meta/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for Meta 4 | * 5 | */ 6 | 7 | import React from 'react' 8 | import { renderProvider } from '@utils/testUtils' 9 | import Meta from '../index' 10 | 11 | describe('', () => { 12 | it('should render and match the snapshot', () => { 13 | const { baseElement } = renderProvider() 14 | expect(baseElement).toMatchSnapshot() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /app/components/Recommended/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Recommended 4 | * 5 | */ 6 | 7 | import React from 'react'; 8 | import { Row, Col } from 'antd'; 9 | import PropTypes from 'prop-types'; 10 | import { useRouter } from 'next/router'; 11 | import { ClickableTags } from '../styled/repos'; 12 | 13 | const Recommended = (props) => { 14 | const { recommendations } = props; 15 | const router = useRouter(); 16 | return ( 17 | 18 | {recommendations.map(({ id, name }) => ( 19 | router.push(`/info/${name}`)}> 20 | {name} 21 | 22 | ))} 23 | 24 | ); 25 | }; 26 | 27 | Recommended.propTypes = { 28 | recommendations: PropTypes.arrayOf( 29 | PropTypes.shape({ id: PropTypes.number.isRequired, name: PropTypes.string.isRequired }) 30 | ) 31 | }; 32 | 33 | export default Recommended; 34 | -------------------------------------------------------------------------------- /app/components/Recommended/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render and match the snapshot 1`] = ` 4 | .emotion-2 { 5 | cursor: pointer; 6 | } 7 | 8 | .emotion-2:hover { 9 | border: 1px solid #006ED6; 10 | } 11 | 12 | 13 |
14 |
18 |
21 | 24 | test repo name 25 | 26 |
27 |
28 |
29 | 30 | `; 31 | -------------------------------------------------------------------------------- /app/components/Recommended/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for Recommended 4 | * 5 | */ 6 | 7 | import React from 'react' 8 | // import { fireEvent } from '@testing-library/dom' 9 | import { renderProvider } from '@utils/testUtils' 10 | import Recommended from '../index' 11 | 12 | describe('', () => { 13 | const recommendations = [ 14 | { 15 | name: 'test repo name', 16 | id: 1 17 | } 18 | ] 19 | it('should render and match the snapshot', () => { 20 | const { baseElement } = renderProvider( 21 | 22 | ) 23 | expect(baseElement).toMatchSnapshot() 24 | }) 25 | 26 | it('should contain 1 Recommended component', () => { 27 | const { getAllByTestId } = renderProvider( 28 | 29 | ) 30 | expect(getAllByTestId('recommended').length).toBe(1) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /app/components/RepoList/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * RepoList 4 | * 5 | */ 6 | 7 | import React from 'react'; 8 | import get from 'lodash/get'; 9 | import { Skeleton } from 'antd'; 10 | import PropTypes from 'prop-types'; 11 | import { useRouter } from 'next/router'; 12 | import T from '@components/Text'; 13 | import If from '@components/If'; 14 | import { CustomCard } from '@components/styled/repos'; 15 | 16 | const RepoList = (props) => { 17 | const { reposData, loading, repoName } = props; 18 | const router = useRouter(); 19 | 20 | const items = get(reposData, 'items', []); 21 | const totalCount = get(reposData, 'totalCount', 0); 22 | const BlockText = (blockTextProps) => ; 23 | return ( 24 | 25 | 26 | 27 | {repoName && } 28 | {totalCount !== 0 && } 29 | {items.map((item, index) => ( 30 | router.push(`/info/${item?.name}?owner=${item?.owner.login}`)}> 31 | 32 | 33 | 34 | 35 | ))} 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | RepoList.propTypes = { 43 | loading: PropTypes.bool.isRequired, 44 | reposData: PropTypes.arrayOf( 45 | PropTypes.shape({ 46 | totalCount: PropTypes.number, 47 | incompleteResults: PropTypes.bool, 48 | items: PropTypes.array 49 | }) 50 | ), 51 | repoName: PropTypes.string 52 | }; 53 | 54 | export default RepoList; 55 | -------------------------------------------------------------------------------- /app/components/RepoList/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render and match the snapshot 1`] = ` 4 | .emotion-0.emotion-0 { 5 | margin: 20px 0; 6 | } 7 | 8 | 9 |
10 |
14 |
17 |
20 |
23 |

27 |
    30 |
  • 31 |
  • 32 |
  • 35 |
36 |

37 |
38 |
39 |
40 |
41 | 42 | `; 43 | -------------------------------------------------------------------------------- /app/components/RepoList/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for RepoList 4 | * 5 | */ 6 | 7 | import React from 'react' 8 | // import { fireEvent } from '@testing-library/dom' 9 | import { renderProvider } from '@utils/testUtils' 10 | import RepoList from '../index' 11 | 12 | describe('', () => { 13 | it('should render and match the snapshot', () => { 14 | const { baseElement } = renderProvider() 15 | expect(baseElement).toMatchSnapshot() 16 | }) 17 | 18 | it('should contain 1 RepoList component', () => { 19 | const { getAllByTestId } = renderProvider() 20 | expect(getAllByTestId('repo-list').length).toBe(1) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /app/components/Text/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Text 4 | * 5 | */ 6 | 7 | import React, { memo } from 'react'; 8 | import PropTypes from 'prop-types'; 9 | import styled from '@emotion/styled'; 10 | import { FormattedMessage as T } from 'react-intl'; 11 | 12 | const StyledText = styled.span` 13 | white-space: pre-line; 14 | ${({ display }) => display && `display: ${display}`}; 15 | ${(props) => props.color && `color: ${props.color}`}; 16 | ${(props) => props.fontsize}; 17 | ${(props) => props.fontweight}; 18 | ${(props) => props.styles}; 19 | `; 20 | 21 | /** 22 | * A component for displaying text 23 | * @param {string} id - The id of the text to display 24 | * @param {string} text - The text to display 25 | * @param {object} values - The values to pass to the text 26 | * @param {string} color - The color of the text 27 | * @param {string} fontWeight - The font weight of the text 28 | * @param {string} fontSize - The font size of the text 29 | * @param {string} display - The display type of the text 30 | */ 31 | function Text({ id = 'default', text, values = {}, children, color, fontWeight, fontSize, ...props }) { 32 | return ( 33 | 34 | {text || children || } 35 | 36 | ); 37 | } 38 | 39 | Text.propTypes = { 40 | id: PropTypes.string, 41 | text: PropTypes.string, 42 | children: PropTypes.string, 43 | values: PropTypes.object, 44 | color: PropTypes.string, 45 | fontWeight: PropTypes.array, 46 | fontSize: PropTypes.array, 47 | display: PropTypes.oneOf(['block', 'in-line']) 48 | }; 49 | 50 | export default memo(Text); 51 | -------------------------------------------------------------------------------- /app/components/Text/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render and match the snapshot 1`] = ` 4 | .emotion-0 { 5 | white-space: pre-line; 6 | } 7 | 8 | 9 |
10 | 14 | default 15 | 16 |
17 | 18 | `; 19 | -------------------------------------------------------------------------------- /app/components/Text/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for Text 4 | * 5 | */ 6 | 7 | import React from 'react' 8 | // import { fireEvent } from '@testing-library/dom' 9 | import { renderProvider } from '@utils/testUtils' 10 | import Text from '../index' 11 | 12 | describe('', () => { 13 | it('should render and match the snapshot', () => { 14 | const { baseElement } = renderProvider() 15 | expect(baseElement).toMatchSnapshot() 16 | }) 17 | 18 | it('should contain 1 Text component', () => { 19 | const { getAllByTestId } = renderProvider() 20 | expect(getAllByTestId('text').length).toBe(1) 21 | }) 22 | 23 | it('should create a span with display value that is passed as prop', () => { 24 | const { queryByTestId } = renderProvider() 25 | expect(queryByTestId('text')).toHaveStyle({ display: 'block' }) 26 | }) 27 | 28 | it('should not set a display value when a display prop is not passed', () => { 29 | const { queryByTestId } = renderProvider() 30 | expect(queryByTestId('text')).not.toHaveStyle({ display: 'block' }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /app/components/Title/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Title 4 | * 5 | */ 6 | 7 | import React from 'react'; 8 | import PropTypes from 'prop-types'; 9 | import { Row, Skeleton } from 'antd'; 10 | import { StarOutlined } from '@ant-design/icons'; 11 | import Text from '@app/components/Text'; 12 | import fonts from '@app/themes/fonts'; 13 | 14 | /** 15 | * The title of the info container 16 | * @param {object} props The component props 17 | * @param {string} props.name The name of the repo 18 | * @param {boolean} props.loading Whether the data is loading 19 | * @param {number} props.stargazersCount The number of stargazers 20 | * @returns {JSX.Element} The title of the info container 21 | */ 22 | function Title(props) { 23 | const { name, loading, stargazersCount } = props; 24 | const headingStyle = fonts.style.heading(); 25 | return ( 26 | 27 | 28 | {name} 29 | 30 | ( {stargazersCount} ) 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | Title.propTypes = { 38 | loading: PropTypes.bool.isRequired, 39 | name: PropTypes.string.isRequired, 40 | stargazersCount: PropTypes.number.isRequired 41 | }; 42 | 43 | export default Title; 44 | -------------------------------------------------------------------------------- /app/components/Title/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render and match the snapshot 1`] = ` 4 | .emotion-1 { 5 | white-space: pre-line; 6 | font-size: 2.25rem; 7 | font-weight: 500; 8 | } 9 | 10 | <body> 11 | <div> 12 | <div 13 | class="ant-row ant-row-space-between ant-row-middle emotion-0" 14 | data-testid="title" 15 | > 16 | <span 17 | class="emotion-1" 18 | data-testid="text" 19 | > 20 | default 21 | </span> 22 | <span 23 | class="emotion-1" 24 | data-testid="text" 25 | > 26 | <span 27 | aria-label="star" 28 | class="anticon anticon-star" 29 | role="img" 30 | > 31 | <svg 32 | aria-hidden="true" 33 | data-icon="star" 34 | fill="currentColor" 35 | focusable="false" 36 | height="1em" 37 | viewBox="64 64 896 896" 38 | width="1em" 39 | > 40 | <path 41 | d="M908.1 353.1l-253.9-36.9L540.7 86.1c-3.1-6.3-8.2-11.4-14.5-14.5-15.8-7.8-35-1.3-42.9 14.5L369.8 316.2l-253.9 36.9c-7 1-13.4 4.3-18.3 9.3a32.05 32.05 0 00.6 45.3l183.7 179.1-43.4 252.9a31.95 31.95 0 0046.4 33.7L512 754l227.1 119.4c6.2 3.3 13.4 4.4 20.3 3.2 17.4-3 29.1-19.5 26.1-36.9l-43.4-252.9 183.7-179.1c5-4.9 8.3-11.3 9.3-18.3 2.7-17.5-9.5-33.7-27-36.3zM664.8 561.6l36.1 210.3L512 672.7 323.1 772l36.1-210.3-152.8-149L417.6 382 512 190.7 606.4 382l211.2 30.7-152.8 148.9z" 42 | /> 43 | </svg> 44 | </span> 45 | ( 46 | ) 47 | </span> 48 | </div> 49 | </div> 50 | </body> 51 | `; 52 | -------------------------------------------------------------------------------- /app/components/Title/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for Title 4 | * 5 | */ 6 | 7 | import React from 'react' 8 | // import { fireEvent } from '@testing-library/dom' 9 | import { renderProvider } from '@utils/testUtils' 10 | import Title from '../index' 11 | 12 | describe('<Title />', () => { 13 | it('should render and match the snapshot', () => { 14 | const { baseElement } = renderProvider(<Title />) 15 | expect(baseElement).toMatchSnapshot() 16 | }) 17 | 18 | it('should contain 1 Title component', () => { 19 | const { getAllByTestId } = renderProvider(<Title />) 20 | expect(getAllByTestId('title').length).toBe(1) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /app/components/styled/index.js: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const Container = styled.div` 4 | && { 5 | display: flex; 6 | flex-direction: column; 7 | max-width: ${(props) => props.maxwidth}px; 8 | width: 100%; 9 | margin: 0 auto; 10 | padding: ${(props) => props.padding}px; 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /app/components/styled/repos.js: -------------------------------------------------------------------------------- 1 | import { Card, Tag } from 'antd'; 2 | import styled from '@emotion/styled'; 3 | import { colors } from '@themes'; 4 | 5 | export const CustomCard = styled(Card)` 6 | && { 7 | margin: 20px 0; 8 | max-width: ${(props) => props.maxwidth}; 9 | color: ${(props) => props.color}; 10 | ${(props) => props.color && `color: ${props.color}`}; 11 | } 12 | `; 13 | 14 | export const YouAreAwesome = styled.a` 15 | text-align: right; 16 | 17 | && { 18 | span { 19 | color: ${colors.primary}; 20 | text-decoration: underline; 21 | :hover { 22 | opacity: 0.8; 23 | } 24 | } 25 | } 26 | `; 27 | 28 | export const ClickableTags = styled(Tag)` 29 | cursor: pointer; 30 | :hover { 31 | border: 1px solid ${colors.primary}; 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /app/components/styled/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`<Container/> should ensure it match the snapshot 1`] = `undefined`; 4 | -------------------------------------------------------------------------------- /app/components/styled/tests/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Container } from '../index' 3 | import { render } from '@testing-library/react' 4 | 5 | describe('<Container/>', () => { 6 | it('should ensure it match the snapshot', () => { 7 | const { Element } = render(<Container />) 8 | expect(Element).toMatchSnapshot() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /app/configureStore.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create the store with dynamic reducers 3 | */ 4 | 5 | import { createStore, applyMiddleware, compose } from 'redux'; 6 | import createSagaMiddleware from 'redux-saga'; 7 | import { createWrapper } from 'next-redux-wrapper'; 8 | 9 | import createReducer from './reducers'; 10 | 11 | /** 12 | * 13 | * @param {object} initialState The initial state 14 | * @returns {object} The store 15 | * 16 | */ 17 | export default function configureStore(initialState = {}) { 18 | const composeEnhancers = compose; 19 | const reduxSagaMonitorOptions = {}; 20 | 21 | configureDevTools(composeEnhancers); 22 | 23 | const sagaMiddleware = createSagaMiddleware(reduxSagaMonitorOptions); 24 | 25 | // Create the store with two middlewares 26 | // 1. sagaMiddleware: Makes redux-sagas work 27 | const middlewares = [sagaMiddleware]; 28 | 29 | const enhancers = [applyMiddleware(...middlewares)]; 30 | 31 | const store = createStore(createReducer(), initialState, composeEnhancers(...enhancers)); 32 | 33 | // Extensions 34 | store.runSaga = sagaMiddleware.run; 35 | store.injectedReducers = {}; // Reducer registry 36 | store.injectedSagas = {}; // Saga registry 37 | 38 | // Make reducers hot reloadable, see http://mxs.is/googmo 39 | /* istanbul ignore next */ 40 | if (module.hot) { 41 | module.hot.accept('./reducers', () => { 42 | store.replaceReducer(createReducer(store.injectedReducers)); 43 | }); 44 | } 45 | 46 | return store; 47 | } 48 | 49 | function configureDevTools(composeEnhancers) { 50 | // If Redux Dev Tools and Saga Dev Tools Extensions are installed, enable them 51 | /* istanbul ignore next */ 52 | if (process.env.NODE_ENV !== 'production' && typeof window === 'object') { 53 | if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { 54 | composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}); 55 | } 56 | 57 | // NOTE: Uncomment the code below to restore support for Redux Saga 58 | // Dev Tools once it supports redux-saga version 1.x.x 59 | // if (window.__SAGA_MONITOR_EXTENSION__) 60 | // reduxSagaMonitorOptions = { 61 | // sagaMonitor: window.__SAGA_MONITOR_EXTENSION__, 62 | // }; 63 | } 64 | } 65 | 66 | export const wrapper = createWrapper(configureStore, { debug: process.env.NODE_ENV === 'development' }); 67 | -------------------------------------------------------------------------------- /app/containers/Info/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Info Container 4 | * 5 | */ 6 | import Text from '@app/components/Text'; 7 | import fonts from '@app/themes/fonts'; 8 | import injectSaga from '@app/utils/injectSaga'; 9 | import { Container } from '@components/styled'; 10 | import Title from '@components/Title'; 11 | import { Card, Col, Row, Skeleton } from 'antd'; 12 | import isEmpty from 'lodash/isEmpty'; 13 | import { useRouter } from 'next/router'; 14 | import PropTypes from 'prop-types'; 15 | import React, { useEffect } from 'react'; 16 | import { injectIntl } from 'react-intl'; 17 | import { connect } from 'react-redux'; 18 | import { compose } from 'redux'; 19 | import { createStructuredSelector } from 'reselect'; 20 | 21 | import { infoCreators } from './reducer'; 22 | import saga from './saga'; 23 | import { selectInfoData, selectInfoLoading } from './selectors'; 24 | 25 | /** 26 | * The Info container 27 | * @param {object} props The component props 28 | * @param {object} props.details The details of the repo 29 | * @param {object} props.params The params from the route 30 | * @param {boolean} props.loading Whether the data is loading 31 | * @param {function} props.dispatchRequestInfo The function to request the info 32 | * @param {object} props.fallBackDetails The details to fall back on 33 | * @returns {JSX.Element} The Info container 34 | */ 35 | export function Info({ details, params, loading, dispatchRequestInfo, fallBackDetails }) { 36 | const router = useRouter(); 37 | const { query } = router; 38 | const { name, description, stargazersCount } = { ...(details || {}), ...(fallBackDetails || {}) }; 39 | 40 | const shouldRequestInfo = () => isEmpty(details) && !!params?.name && !!query?.owner; 41 | useEffect(() => { 42 | const shouldReqInfo = shouldRequestInfo(); 43 | if (shouldReqInfo) { 44 | dispatchRequestInfo(params?.name, query?.owner); 45 | } 46 | }, [params]); 47 | 48 | const shouldLoad = loading || isEmpty({ ...fallBackDetails, ...details }); 49 | 50 | return ( 51 | <Row justify="center" align="middle" style={{ height: '100vh' }} flex="1 1 90%"> 52 | <Col> 53 | <Container style={{ minWidth: '30rem' }} padding={20}> 54 | <Card 55 | title={React.createElement(Title, { 56 | name, 57 | loading: shouldLoad, 58 | stargazersCount 59 | })} 60 | > 61 | <Skeleton loading={shouldLoad} active> 62 | <Text styles={fonts.style.subheading()}>{description}</Text> 63 | </Skeleton> 64 | </Card> 65 | </Container> 66 | </Col> 67 | </Row> 68 | ); 69 | } 70 | 71 | Info.propTypes = { 72 | details: PropTypes.shape({ 73 | name: PropTypes.string.isRequired, 74 | description: PropTypes.string.isRequired, 75 | stargazersCount: PropTypes.number.isRequired 76 | }), 77 | params: PropTypes.shape({ 78 | name: PropTypes.string.isRequired 79 | }), 80 | loading: PropTypes.bool.isRequired, 81 | dispatchRequestInfo: PropTypes.func.isRequired, 82 | fallBackDetails: PropTypes.object 83 | }; 84 | 85 | const mapStateToProps = createStructuredSelector({ 86 | loading: selectInfoLoading(), 87 | fallBackDetails: selectInfoData() 88 | }); 89 | 90 | /** 91 | * The mapDispatchToProps 92 | * @param {function} dispatch The dispatch function 93 | * @returns {object} The props 94 | */ 95 | function mapDispatchToProps(dispatch) { 96 | return { 97 | dispatchRequestInfo: (repo, owner) => dispatch(infoCreators.requestInfo(repo, owner)) 98 | }; 99 | } 100 | 101 | const withConnect = connect(mapStateToProps, mapDispatchToProps); 102 | 103 | export const InfoTest = compose(injectIntl)(Info); 104 | 105 | export default compose(withConnect, injectSaga({ key: 'info', saga }))(Info); 106 | -------------------------------------------------------------------------------- /app/containers/Info/reducer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * /info reducer 4 | * 5 | */ 6 | import produce from 'immer'; 7 | import { createActions } from 'reduxsauce'; 8 | import { setError, setData, startLoading, stopLoading, PAYLOAD } from '../../utils/reducer'; 9 | 10 | export const INFO_PAYLOAD = { 11 | REPO: 'repo', 12 | OWNER: 'owner' 13 | }; 14 | 15 | export const initialState = { [PAYLOAD.LOADING]: false, [PAYLOAD.DATA]: {}, [PAYLOAD.ERROR]: null }; 16 | 17 | export const { Types: infoTypes, Creators: infoCreators } = createActions({ 18 | requestInfo: [INFO_PAYLOAD.REPO, INFO_PAYLOAD.OWNER], 19 | successInfo: [PAYLOAD.DATA], 20 | failureInfo: [PAYLOAD.ERROR] 21 | }); 22 | 23 | export const infoReducer = (state = initialState, action) => 24 | produce(state, (draft) => { 25 | switch (action.type) { 26 | case infoTypes.REQUEST_INFO: 27 | startLoading(draft); 28 | break; 29 | case infoTypes.SUCCESS_INFO: 30 | stopLoading(draft); 31 | setData(draft, action); 32 | break; 33 | case infoTypes.FAILURE_INFO: 34 | stopLoading(draft); 35 | setError(draft); 36 | break; 37 | default: 38 | draft = initialState; 39 | } 40 | }); 41 | 42 | export default infoReducer; 43 | -------------------------------------------------------------------------------- /app/containers/Info/saga.js: -------------------------------------------------------------------------------- 1 | import { put, call, takeLatest } from 'redux-saga/effects'; 2 | import { getRepo } from '@services/info'; 3 | import { ERRORS } from '@app/utils/constants'; 4 | import { infoTypes, infoCreators, INFO_PAYLOAD } from './reducer'; 5 | 6 | /** 7 | * Request info from the API 8 | * @param {object} action 9 | * @param {string} action[INFO_PAYLOAD.REPO] - The name of the repository 10 | * @param {string} action[INFO_PAYLOAD.OWNER] - The owner of the repository 11 | * @returns {object} - The response from the API 12 | */ 13 | export function* requestInfo(action) { 14 | try { 15 | if (!action[INFO_PAYLOAD.REPO] || !action[INFO_PAYLOAD.OWNER]) { 16 | throw new Error(ERRORS.INSUFFICIENT_INFO); 17 | } 18 | const response = yield call(getRepo, action[INFO_PAYLOAD.REPO], action[INFO_PAYLOAD.OWNER]); 19 | yield put(infoCreators.successInfo(response)); 20 | } catch (error) { 21 | console.error(error.message); 22 | yield put(infoCreators.failureInfo(error.message)); 23 | } 24 | } 25 | 26 | /** 27 | * The root of the info saga 28 | * @returns {void} 29 | */ 30 | export default function* appSaga() { 31 | yield takeLatest(infoTypes.REQUEST_INFO, requestInfo); 32 | } 33 | -------------------------------------------------------------------------------- /app/containers/Info/selectors.js: -------------------------------------------------------------------------------- 1 | import { PAYLOAD } from '@app/utils/reducer'; 2 | import { createSelector } from 'reselect'; 3 | import get from 'lodash/get'; 4 | import { initialState } from './reducer'; 5 | 6 | const selectInfoDomain = (state) => state.info || initialState; 7 | 8 | export const selectInfoLoading = () => createSelector(selectInfoDomain, (substate) => get(substate, PAYLOAD.LOADING)); 9 | export const selectInfoData = () => createSelector(selectInfoDomain, (substate) => get(substate, PAYLOAD.DATA, {})); 10 | -------------------------------------------------------------------------------- /app/containers/Info/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`<Info /> container tests should render and match the snapshot 1`] = ` 4 | .emotion-2.emotion-2 { 5 | display: -webkit-box; 6 | display: -webkit-flex; 7 | display: -ms-flexbox; 8 | display: flex; 9 | -webkit-flex-direction: column; 10 | -ms-flex-direction: column; 11 | flex-direction: column; 12 | max-width: px; 13 | width: 100%; 14 | margin: 0 auto; 15 | padding: 20px; 16 | } 17 | 18 | <body> 19 | <div> 20 | <div 21 | class="ant-row ant-row-center ant-row-middle emotion-0" 22 | flex="1 1 90%" 23 | style="height: 100vh;" 24 | > 25 | <div 26 | class="ant-col emotion-0" 27 | > 28 | <div 29 | class="emotion-2" 30 | style="min-width: 30rem;" 31 | > 32 | <div 33 | class="ant-card ant-card-bordered emotion-0" 34 | > 35 | <div 36 | class="ant-card-head" 37 | > 38 | <div 39 | class="ant-card-head-wrapper" 40 | > 41 | <div 42 | class="ant-card-head-title" 43 | > 44 | <div 45 | class="ant-row ant-row-space-between ant-row-middle emotion-0" 46 | data-testid="title" 47 | > 48 | <div 49 | class="ant-skeleton ant-skeleton-active emotion-0" 50 | > 51 | <div 52 | class="ant-skeleton-content" 53 | > 54 | <h3 55 | class="ant-skeleton-title" 56 | style="width: 38%;" 57 | /> 58 | <ul 59 | class="ant-skeleton-paragraph" 60 | > 61 | <li /> 62 | <li /> 63 | <li 64 | style="width: 61%;" 65 | /> 66 | </ul> 67 | </div> 68 | </div> 69 | </div> 70 | </div> 71 | </div> 72 | </div> 73 | <div 74 | class="ant-card-body" 75 | > 76 | <div 77 | class="ant-skeleton ant-skeleton-active emotion-0" 78 | > 79 | <div 80 | class="ant-skeleton-content" 81 | > 82 | <h3 83 | class="ant-skeleton-title" 84 | style="width: 38%;" 85 | /> 86 | <ul 87 | class="ant-skeleton-paragraph" 88 | > 89 | <li /> 90 | <li /> 91 | <li 92 | style="width: 61%;" 93 | /> 94 | </ul> 95 | </div> 96 | </div> 97 | </div> 98 | </div> 99 | </div> 100 | </div> 101 | </div> 102 | </div> 103 | </body> 104 | `; 105 | -------------------------------------------------------------------------------- /app/containers/Info/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for Info container 4 | * 5 | * 6 | */ 7 | 8 | import React from 'react' 9 | // import { fireEvent } from '@testing-library/dom'; 10 | import { renderProvider } from '@utils/testUtils' 11 | import { InfoTest as Info } from '../index' 12 | 13 | describe('<Info /> container tests', () => { 14 | // let submitSpy 15 | 16 | beforeEach(() => { 17 | // submitSpy = jest.fn(); 18 | }) 19 | 20 | it('should render and match the snapshot', () => { 21 | const { baseElement } = renderProvider(<Info />) 22 | expect(baseElement).toMatchSnapshot() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /app/containers/Info/tests/reducer.test.js: -------------------------------------------------------------------------------- 1 | import { infoReducer, infoTypes, initialState } from '../reducer' 2 | 3 | describe('Info reducer tests', () => { 4 | let state 5 | beforeEach(() => { 6 | state = initialState 7 | }) 8 | 9 | it('should return the initial state', () => { 10 | expect(infoReducer(undefined, {})).toEqual(state) 11 | }) 12 | 13 | it('should return the initial state when an action of type REQUEST_INFO is dispatched', () => { 14 | const expectedResult = { ...state, loading: true } 15 | expect( 16 | infoReducer(state, { 17 | type: infoTypes.REQUEST_INFO 18 | }) 19 | ).toEqual(expectedResult) 20 | }) 21 | 22 | it('should ensure that the user data is present and loading = false when SUCCESS_INFO is dispatched', () => { 23 | const data = { name: 'Mohammed Ali Chherawalla' } 24 | const expectedResult = { ...state, data, loading: false } 25 | expect( 26 | infoReducer(state, { 27 | type: infoTypes.SUCCESS_INFO, 28 | data 29 | }) 30 | ).toEqual(expectedResult) 31 | }) 32 | 33 | it('should ensure that the userErrorMessage has some data and loading = false when FAILURE_INFO is dispatched', () => { 34 | const error = 'something_went_wrong' 35 | const expectedResult = { ...state, error, loading: false } 36 | expect( 37 | infoReducer(state, { 38 | type: infoTypes.FAILURE_INFO, 39 | error 40 | }) 41 | ).toEqual(expectedResult) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /app/containers/Info/tests/saga.test.js: -------------------------------------------------------------------------------- 1 | import { takeLatest, call } from 'redux-saga/effects' 2 | import { getRepo } from '@services/info' 3 | import appSaga, { requestInfo } from '../saga' 4 | import { infoTypes } from '../reducer' 5 | 6 | describe('InfoContainer saga tests', () => { 7 | const generator = appSaga() 8 | const repo = 'mac' 9 | const owner = 'wednesday' 10 | let getGithubReposGenerator = requestInfo({ repo, owner }) 11 | 12 | it('should start task to watch for REQUEST_INFO action', () => { 13 | expect(generator.next().value).toEqual( 14 | takeLatest(infoTypes.REQUEST_INFO, requestInfo) 15 | ) 16 | }) 17 | 18 | it('should ensure that the action failureInfo is dispatched when the api call fails', () => { 19 | const res = getGithubReposGenerator.next().value 20 | expect(res).toEqual(call(getRepo, repo, owner)) 21 | }) 22 | 23 | it('should ensure that the action SUCCESS_INFO is dispatched when the api call succeeds', () => { 24 | getGithubReposGenerator = requestInfo({ repo, owner }) 25 | const res = getGithubReposGenerator.next().value 26 | expect(res).toEqual(call(getRepo, repo, owner)) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /app/containers/Info/tests/selectors.test.js: -------------------------------------------------------------------------------- 1 | import { selectInfoLoading, selectInfoData } from '../selectors' 2 | 3 | describe('info selector tests', () => { 4 | let loading 5 | let data 6 | let mockedState 7 | 8 | beforeEach(() => { 9 | loading = true 10 | data = { 11 | test: 'passed!' 12 | } 13 | mockedState = { 14 | info: { 15 | loading, 16 | data 17 | } 18 | } 19 | }) 20 | it('should select the infoLoading selector', () => { 21 | const selectInfoLoadingSelector = selectInfoLoading() 22 | expect(selectInfoLoadingSelector(mockedState)).toBeTruthy() 23 | }) 24 | it('should select the selectInfoData selector', () => { 25 | const selectInfoDataSelector = selectInfoData() 26 | expect(selectInfoDataSelector(mockedState)).toStrictEqual(data) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /app/containers/Repos/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Repos Container 4 | * 5 | */ 6 | import Recommended from '@app/components/Recommended'; 7 | import { Container } from '@app/components/styled'; 8 | import ErrorState from '@components/ErrorState'; 9 | import RepoList from '@components/RepoList'; 10 | import { CustomCard, YouAreAwesome } from '@components/styled/repos'; 11 | import T from '@components/Text'; 12 | import { fonts } from '@themes'; 13 | import { Divider, Input, Row } from 'antd'; 14 | import debounce from 'lodash/debounce'; 15 | import isEmpty from 'lodash/isEmpty'; 16 | import PropTypes from 'prop-types'; 17 | import React, { useEffect } from 'react'; 18 | import { injectIntl } from 'react-intl'; 19 | import { connect } from 'react-redux'; 20 | import { compose } from 'redux'; 21 | import injectSaga from '@utils/injectSaga'; 22 | import { createStructuredSelector } from 'reselect'; 23 | 24 | import { reposActionCreators } from './reducer'; 25 | import saga from './saga'; 26 | import { selectReposData, selectReposError, selectReposSearchKey } from './selectors'; 27 | 28 | /** 29 | * The Repos container 30 | * @param {object} props The component props 31 | * @param {object} props.intl The intl object 32 | * @param {string} props.searchKey The search key 33 | * @param {object} props.repos The repos data 34 | * @param {string} props.error The error message 35 | * @param {boolean} props.loading Whether the data is loading 36 | * @param {object} props.recommendations The list of recommendations 37 | * @param {function} props.dispatchGetGithubRepos The function to get the github repos 38 | * @param {function} props.dispatchClearGithubRepos The function to clear the github repos 39 | * @returns {JSX.Element} The Repos container 40 | */ 41 | export function Repos({ 42 | intl, 43 | repos, 44 | error, 45 | loading, 46 | searchKey, 47 | recommendations, 48 | dispatchGetGithubRepos, 49 | dispatchClearGithubRepos 50 | }) { 51 | useEffect(() => { 52 | if (repos && !repos?.items?.length) { 53 | dispatchGetGithubRepos(searchKey); 54 | } 55 | }, []); 56 | 57 | const handleOnChange = debounce((rName) => { 58 | if (!isEmpty(rName)) { 59 | dispatchGetGithubRepos(rName); 60 | } else { 61 | dispatchClearGithubRepos(); 62 | } 63 | }, 200); 64 | 65 | return ( 66 | <Container 67 | padding={20} 68 | maxwidth={500} 69 | style={{ 70 | height: '100vh', 71 | alignSelf: 'center' 72 | }} 73 | > 74 | <Row> 75 | <T id="recommended" styles={fonts.style.subheading()} /> 76 | </Row> 77 | <Row justify="space-between"> 78 | <Recommended recommendations={recommendations} /> 79 | <YouAreAwesome href="https://www.iamawesome.com/"> 80 | <T id="you_are_awesome" /> 81 | </YouAreAwesome> 82 | </Row> 83 | <Divider /> 84 | <CustomCard title={intl.formatMessage({ id: 'repo_search' })} maxwidth={500}> 85 | <T marginBottom={10} id="get_repo_details" /> 86 | <Input.Search 87 | data-testid="search-bar" 88 | defaultValue={searchKey} 89 | type="text" 90 | onChange={(evt) => handleOnChange(evt.target.value)} 91 | onSearch={(searchText) => handleOnChange(searchText)} 92 | /> 93 | </CustomCard> 94 | <RepoList reposData={repos} loading={loading} repoName={searchKey} /> 95 | <ErrorState reposData={repos} loading={loading} reposError={error} /> 96 | </Container> 97 | ); 98 | } 99 | 100 | Repos.propTypes = { 101 | intl: PropTypes.any, 102 | searchKey: PropTypes.string, 103 | repos: PropTypes.shape({ 104 | totalCount: PropTypes.number, 105 | incompleteResults: PropTypes.bool, 106 | items: PropTypes.array 107 | }), 108 | error: PropTypes.string, 109 | loading: PropTypes.bool.isRequired, 110 | recommendations: PropTypes.arrayOf( 111 | PropTypes.shape({ id: PropTypes.number.isRequired, name: PropTypes.string.isRequired }) 112 | ), 113 | dispatchGetGithubRepos: PropTypes.func, 114 | dispatchClearGithubRepos: PropTypes.func 115 | }; 116 | 117 | Repos.defaultProps = { 118 | padding: 20, 119 | maxwidth: 500 120 | }; 121 | const mapStateToProps = createStructuredSelector({ 122 | repos: selectReposData(), 123 | error: selectReposError(), 124 | searchKey: selectReposSearchKey() 125 | }); 126 | 127 | /** 128 | * The mapDispatchToProps 129 | * @param {function} dispatch The dispatch function 130 | * @returns {object} The props 131 | */ 132 | function mapDispatchToProps(dispatch) { 133 | const { requestGetGithubRepos, clearGithubRepos } = reposActionCreators; 134 | return { 135 | dispatchClearGithubRepos: () => dispatch(clearGithubRepos()), 136 | dispatchGetGithubRepos: (repoName) => dispatch(requestGetGithubRepos(repoName)) 137 | }; 138 | } 139 | 140 | const withConnect = connect(mapStateToProps, mapDispatchToProps); 141 | 142 | export default compose(withConnect, injectIntl, injectSaga({ key: 'repos', saga }))(Repos); 143 | 144 | export const ReposTest = compose(injectIntl)(Repos); 145 | -------------------------------------------------------------------------------- /app/containers/Repos/reducer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * Repos reducer 4 | * 5 | */ 6 | import { PAYLOAD, startLoading, stopLoading, setError, setData } from '@app/utils/reducer'; 7 | import produce from 'immer'; 8 | import { createActions } from 'reduxsauce'; 9 | 10 | export const REPOS_PAYLOAD = { 11 | SEARCH_KEY: 'searchKey' 12 | }; 13 | 14 | export const initialState = { 15 | [REPOS_PAYLOAD.SEARCH_KEY]: null, 16 | [PAYLOAD.DATA]: {}, 17 | [PAYLOAD.ERROR]: null 18 | }; 19 | 20 | export const { Types: reposActionTypes, Creators: reposActionCreators } = createActions({ 21 | requestGetGithubRepos: [REPOS_PAYLOAD.SEARCH_KEY], 22 | successGetGithubRepos: [PAYLOAD.DATA], 23 | failureGetGithubRepos: [PAYLOAD.ERROR], 24 | clearGithubRepos: null 25 | }); 26 | 27 | export const reposReducer = (state = initialState, action) => 28 | produce(state, (draft) => { 29 | switch (action.type) { 30 | case reposActionTypes.REQUEST_GET_GITHUB_REPOS: 31 | startLoading(draft); 32 | break; 33 | case reposActionTypes.SUCCESS_GET_GITHUB_REPOS: 34 | stopLoading(draft); 35 | setData(draft, action); 36 | break; 37 | case reposActionTypes.FAILURE_GET_GITHUB_REPOS: 38 | stopLoading(draft); 39 | setError(draft); 40 | break; 41 | case reposActionTypes.CLEAR_GITHUB_REPOS: 42 | draft = initialState; 43 | break; 44 | default: 45 | draft = initialState; 46 | } 47 | }); 48 | 49 | export default reposReducer; 50 | -------------------------------------------------------------------------------- /app/containers/Repos/saga.js: -------------------------------------------------------------------------------- 1 | import { call, put, takeLatest } from 'redux-saga/effects'; 2 | import { getRepos } from '@services/repoApi'; 3 | import { reposActionTypes, reposActionCreators, REPOS_PAYLOAD } from './reducer'; 4 | 5 | const { REQUEST_GET_GITHUB_REPOS } = reposActionTypes; 6 | const { successGetGithubRepos, failureGetGithubRepos } = reposActionCreators; 7 | 8 | /** 9 | * Get the github repos 10 | * @param {object} action 11 | * @param {string} action[REPOS_PAYLOAD.SEARCH_KEY] - The search key 12 | * @returns {object} - The response from the API 13 | */ 14 | export function* getGithubRepos(action) { 15 | const response = yield call(getRepos, action[REPOS_PAYLOAD.SEARCH_KEY]); 16 | const { data, ok } = response; 17 | if (ok) { 18 | yield put(successGetGithubRepos(data)); 19 | } else { 20 | yield put(failureGetGithubRepos(data)); 21 | } 22 | } 23 | 24 | /** 25 | * The root of the repos saga 26 | * @returns {void} 27 | * @yields {object} - The response from the API 28 | */ 29 | export default function* appSaga() { 30 | yield takeLatest(REQUEST_GET_GITHUB_REPOS, getGithubRepos); 31 | } 32 | -------------------------------------------------------------------------------- /app/containers/Repos/selectors.js: -------------------------------------------------------------------------------- 1 | import { PAYLOAD } from '@app/utils/reducer'; 2 | import get from 'lodash/get'; 3 | import { createSelector } from 'reselect'; 4 | 5 | import { REPOS_PAYLOAD, initialState } from './reducer'; 6 | 7 | const selectReposDomain = (state) => state.repos || initialState; 8 | 9 | export const selectReposData = () => createSelector(selectReposDomain, (substate) => get(substate, PAYLOAD.DATA, null)); 10 | 11 | export const selectReposError = () => 12 | createSelector(selectReposDomain, (substate) => get(substate, PAYLOAD.ERROR, null)); 13 | 14 | export const selectReposSearchKey = () => 15 | createSelector(selectReposDomain, (substate) => get(substate, REPOS_PAYLOAD.SEARCH_KEY, null)); 16 | -------------------------------------------------------------------------------- /app/containers/Repos/tests/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`<Repos /> container tests should render and match the snapshot 1`] = ` 4 | .emotion-0.emotion-0 { 5 | display: -webkit-box; 6 | display: -webkit-flex; 7 | display: -ms-flexbox; 8 | display: flex; 9 | -webkit-flex-direction: column; 10 | -ms-flex-direction: column; 11 | flex-direction: column; 12 | max-width: 500px; 13 | width: 100%; 14 | margin: 0 auto; 15 | padding: 20px; 16 | } 17 | 18 | .emotion-2 { 19 | white-space: pre-line; 20 | font-size: 1.75rem; 21 | font-weight: 500; 22 | } 23 | 24 | .emotion-5 { 25 | text-align: right; 26 | } 27 | 28 | .emotion-5.emotion-5 span { 29 | color: #006ED6; 30 | -webkit-text-decoration: underline; 31 | text-decoration: underline; 32 | } 33 | 34 | .emotion-5.emotion-5 span:hover { 35 | opacity: 0.8; 36 | } 37 | 38 | .emotion-6 { 39 | white-space: pre-line; 40 | } 41 | 42 | .emotion-8.emotion-8 { 43 | margin: 20px 0; 44 | max-width: 500; 45 | } 46 | 47 | .emotion-15.emotion-15 { 48 | margin: 20px 0; 49 | color: grey; 50 | color: grey; 51 | } 52 | 53 | <body> 54 | <div> 55 | <div 56 | class="emotion-0" 57 | style="height: 100vh; align-self: center;" 58 | > 59 | <div 60 | class="ant-row emotion-1" 61 | > 62 | <span 63 | class="emotion-2" 64 | data-testid="text" 65 | > 66 | Recommended 67 | </span> 68 | </div> 69 | <div 70 | class="ant-row ant-row-space-between emotion-1" 71 | > 72 | <div 73 | class="ant-row emotion-1" 74 | data-testid="recommended" 75 | /> 76 | <a 77 | class="emotion-5" 78 | href="https://www.iamawesome.com/" 79 | > 80 | <span 81 | class="emotion-6" 82 | data-testid="text" 83 | > 84 | You are awesome 85 | </span> 86 | </a> 87 | </div> 88 | <div 89 | class="ant-divider emotion-1 ant-divider-horizontal" 90 | role="separator" 91 | /> 92 | <div 93 | class="ant-card ant-card-bordered emotion-8 emotion-1" 94 | maxwidth="500" 95 | > 96 | <div 97 | class="ant-card-head" 98 | > 99 | <div 100 | class="ant-card-head-wrapper" 101 | > 102 | <div 103 | class="ant-card-head-title" 104 | > 105 | Repository Search 106 | </div> 107 | </div> 108 | </div> 109 | <div 110 | class="ant-card-body" 111 | > 112 | <span 113 | class="emotion-6" 114 | data-testid="text" 115 | > 116 | Get details of repositories 117 | </span> 118 | <span 119 | class="ant-input-group-wrapper ant-input-group-wrapper-outlined emotion-1 ant-input-search" 120 | > 121 | <span 122 | class="ant-input-wrapper ant-input-group emotion-1" 123 | > 124 | <input 125 | class="ant-input emotion-1 ant-input-outlined" 126 | data-testid="search-bar" 127 | type="text" 128 | value="" 129 | /> 130 | <span 131 | class="ant-input-group-addon" 132 | > 133 | <button 134 | class="ant-btn emotion-1 ant-btn-default ant-btn-icon-only ant-input-search-button" 135 | type="button" 136 | > 137 | <span 138 | class="ant-btn-icon" 139 | > 140 | <span 141 | aria-label="search" 142 | class="anticon anticon-search" 143 | role="img" 144 | > 145 | <svg 146 | aria-hidden="true" 147 | data-icon="search" 148 | fill="currentColor" 149 | focusable="false" 150 | height="1em" 151 | viewBox="64 64 896 896" 152 | width="1em" 153 | > 154 | <path 155 | d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z" 156 | /> 157 | </svg> 158 | </span> 159 | </span> 160 | </button> 161 | </span> 162 | </span> 163 | </span> 164 | </div> 165 | </div> 166 | <div 167 | class="ant-card ant-card-bordered emotion-15 emotion-1" 168 | color="grey" 169 | data-testid="error-state" 170 | > 171 | <div 172 | class="ant-card-head" 173 | > 174 | <div 175 | class="ant-card-head-wrapper" 176 | > 177 | <div 178 | class="ant-card-head-title" 179 | > 180 | Repository List 181 | </div> 182 | </div> 183 | </div> 184 | <div 185 | class="ant-card-body" 186 | > 187 | <span 188 | class="emotion-6" 189 | data-testid="text" 190 | > 191 | Search for a repository by entering it's name in the search box 192 | </span> 193 | </div> 194 | </div> 195 | </div> 196 | </div> 197 | </body> 198 | `; 199 | -------------------------------------------------------------------------------- /app/containers/Repos/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Tests for Repos container 4 | * 5 | * 6 | */ 7 | 8 | import React from 'react' 9 | // import { fireEvent } from '@testing-library/dom'; 10 | import { renderProvider } from '@utils/testUtils' 11 | import { ReposTest as Repos } from '../index' 12 | 13 | describe('<Repos /> container tests', () => { 14 | // let submitSpy 15 | 16 | beforeEach(() => { 17 | // submitSpy = jest.fn(); 18 | }) 19 | 20 | it('should render and match the snapshot', () => { 21 | const { baseElement } = renderProvider(<Repos recommendations={[]} />) 22 | expect(baseElement).toMatchSnapshot() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /app/containers/Repos/tests/reducer.test.js: -------------------------------------------------------------------------------- 1 | import { PAYLOAD } from '@app/utils/reducer' 2 | import { 3 | reposReducer, 4 | initialState, 5 | reposActionTypes, 6 | REPOS_PAYLOAD 7 | } from '../../../containers/Repos/reducer' 8 | 9 | describe('Repos reducer tests', () => { 10 | let state 11 | beforeEach(() => { 12 | state = initialState 13 | }) 14 | 15 | it('should return the initial state', () => { 16 | expect(reposReducer(undefined, {})).toEqual(state) 17 | }) 18 | 19 | it('should return the initial state when an action of type REQUEST_GET_GITHUB_REPOS is dispatched', () => { 20 | const repoName = 'Mohammed Ali Chherawalla' 21 | const expectedResult = { ...state, loading: true } 22 | expect( 23 | reposReducer(state, { 24 | type: reposActionTypes.REQUEST_GET_GITHUB_REPOS, 25 | [REPOS_PAYLOAD.SEARCH_KEY]: repoName 26 | }) 27 | ).toEqual(expectedResult) 28 | }) 29 | 30 | it('should ensure that the user data is present and userLoading = false when SUCCESS_GET_GITHUB_REPOS is dispatched', () => { 31 | const data = { name: 'Mohammed Ali Chherawalla' } 32 | const expectedResult = { ...state, data, loading: false } 33 | expect( 34 | reposReducer(state, { 35 | type: reposActionTypes.SUCCESS_GET_GITHUB_REPOS, 36 | data 37 | }) 38 | ).toEqual(expectedResult) 39 | }) 40 | 41 | it('should ensure that the userErrorMessage has some data and userLoading = false when FAILURE_GET_GITHUB_REPOS is dispatched', () => { 42 | const error = 'something_went_wrong' 43 | const expectedResult = { ...state, [PAYLOAD.ERROR]: error, loading: false } 44 | expect( 45 | reposReducer(state, { 46 | type: reposActionTypes.FAILURE_GET_GITHUB_REPOS, 47 | error 48 | }) 49 | ).toEqual(expectedResult) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /app/containers/Repos/tests/saga.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test app sagas 3 | */ 4 | 5 | import { takeLatest, call, put } from 'redux-saga/effects' 6 | import { getRepos } from '@services/repoApi' 7 | import { apiResponseGenerator } from '@utils/testUtils' 8 | import appSaga, { getGithubRepos } from '../../../containers/Repos/saga' 9 | import { reposActionTypes, REPOS_PAYLOAD } from '../reducer' 10 | 11 | describe('App saga tests', () => { 12 | const generator = appSaga() 13 | const repoName = 'mac' 14 | let getGithubReposGenerator = getGithubRepos({ 15 | [REPOS_PAYLOAD.SEARCH_KEY]: repoName 16 | }) 17 | 18 | it('should start task to watch for REQUEST_GET_GITHUB_REPOS action', () => { 19 | expect(generator.next().value).toEqual( 20 | takeLatest(reposActionTypes.REQUEST_GET_GITHUB_REPOS, getGithubRepos) 21 | ) 22 | }) 23 | 24 | it('should ensure that the action FAILURE_GET_GITHUB_REPOS is dispatched when the api call fails', () => { 25 | const res = getGithubReposGenerator.next().value 26 | expect(res).toEqual(call(getRepos, repoName)) 27 | const errorResponse = { 28 | errorMessage: 'There was an error while fetching repo informations.' 29 | } 30 | expect( 31 | getGithubReposGenerator.next(apiResponseGenerator(false, errorResponse)) 32 | .value 33 | ).toEqual( 34 | put({ 35 | type: reposActionTypes.FAILURE_GET_GITHUB_REPOS, 36 | error: errorResponse 37 | }) 38 | ) 39 | }) 40 | 41 | it('should ensure that the action SUCCESS_GET_GITHUB_REPOS is dispatched when the api call succeeds', () => { 42 | getGithubReposGenerator = getGithubRepos({ 43 | [REPOS_PAYLOAD.SEARCH_KEY]: repoName 44 | }) 45 | const res = getGithubReposGenerator.next().value 46 | expect(res).toEqual(call(getRepos, repoName)) 47 | const reposResponse = { 48 | totalCount: 1, 49 | items: [{ repositoryName: repoName }] 50 | } 51 | expect( 52 | getGithubReposGenerator.next(apiResponseGenerator(true, reposResponse)) 53 | .value 54 | ).toEqual( 55 | put({ 56 | type: reposActionTypes.SUCCESS_GET_GITHUB_REPOS, 57 | data: reposResponse 58 | }) 59 | ) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /app/containers/Repos/tests/selectors.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | selectReposSearchKey, 3 | selectReposData, 4 | selectReposError 5 | } from '../selectors' 6 | 7 | describe('app selector tests', () => { 8 | let mockedState 9 | let searchKey 10 | let reposData 11 | let reposError 12 | 13 | beforeEach(() => { 14 | searchKey = 'mac' 15 | reposData = { totalCount: 1, items: [{ searchKey }] } 16 | reposError = 'There was some error while fetching the repository details' 17 | 18 | mockedState = { 19 | repos: { 20 | searchKey, 21 | data: reposData, 22 | error: reposError 23 | } 24 | } 25 | }) 26 | 27 | it('should select the repoName', () => { 28 | const repoSelector = selectReposSearchKey() 29 | expect(repoSelector(mockedState)).toEqual(searchKey) 30 | }) 31 | 32 | it('should select reposData', () => { 33 | const reposDataSelector = selectReposData() 34 | expect(reposDataSelector(mockedState)).toEqual(reposData) 35 | }) 36 | 37 | it('should select the reposError', () => { 38 | const reposErrorSelector = selectReposError() 39 | expect(reposErrorSelector(mockedState)).toEqual(reposError) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /app/global-styles.js: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { colors } from '@themes'; 3 | 4 | const globalStyle = css` 5 | html, 6 | body { 7 | -webkit-overflow-scrolling: touch !important; 8 | scroll-behavior: smooth; 9 | -ms-overflow-style: none; 10 | display: block; 11 | } 12 | 13 | p, 14 | label { 15 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 16 | line-height: 1.5; 17 | color: ${colors.text}; 18 | } 19 | 20 | body { 21 | p, 22 | label, 23 | span, 24 | div, 25 | h1 { 26 | line-height: 1.5; 27 | font-family: Helvetica, Arial, sans-serif; 28 | color: ${colors.text}; 29 | } 30 | } 31 | body.fontLoaded { 32 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 33 | } 34 | 35 | #app { 36 | background-color: #fafafa; 37 | min-height: 100%; 38 | min-width: 100%; 39 | } 40 | 41 | #__next { 42 | height: 100%; 43 | } 44 | `; 45 | 46 | export default globalStyle; 47 | -------------------------------------------------------------------------------- /app/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * i18n.js 3 | * 4 | * This will setup the i18n language files and locale data for your app. 5 | * 6 | * IMPORTANT: This file is used by the internal build 7 | * script `extract-intl`, and must use CommonJS module syntax 8 | * You CANNOT use import/export in this file. 9 | */ 10 | //eslint-disable-line 11 | import '@formatjs/intl-relativetimeformat/polyfill'; 12 | 13 | const enTranslationMessages = require('./lang/en.json'); 14 | 15 | export const DEFAULT_LOCALE = 'en'; 16 | 17 | // prettier-ignore 18 | export const appLocales = [ 19 | 'en', 20 | ]; 21 | 22 | export const formatTranslationMessages = (locale, messages) => { 23 | const defaultFormattedMessages = 24 | locale !== DEFAULT_LOCALE ? formatTranslationMessages(DEFAULT_LOCALE, enTranslationMessages) : {}; 25 | const flattenFormattedMessages = (formattedMessages, key) => { 26 | const formattedMessage = 27 | !messages[key] && locale !== DEFAULT_LOCALE ? defaultFormattedMessages[key] : messages[key]; 28 | return Object.assign(formattedMessages, { [key]: formattedMessage }); 29 | }; 30 | return Object.keys(messages).reduce(flattenFormattedMessages, {}); 31 | }; 32 | 33 | export const translationMessages = { 34 | en: formatTranslationMessages('en', enTranslationMessages) 35 | }; 36 | -------------------------------------------------------------------------------- /app/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wednesday-solutions/nextjs-template/300ddf0c1274741b7c39370fcaf2c0f123e4ab0c/app/images/favicon.ico -------------------------------------------------------------------------------- /app/images/ws-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wednesday-solutions/nextjs-template/300ddf0c1274741b7c39370fcaf2c0f123e4ab0c/app/images/ws-icon.png -------------------------------------------------------------------------------- /app/lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Next.js Template", 3 | "description": "Template for next.js projects", 4 | "repo_list": "Repository List", 5 | "repo_search": "Repository Search", 6 | "respo_search_default": "Search for a repository by entering it's name in the search box", 7 | "get_repo_details": "Get details of repositories", 8 | "search_query": "Search query: {repoName}", 9 | "matching_repos": "Total number of matching repos: {totalCount}", 10 | "something_went_wrong": "Sorry. Something went wrong! Please try again in sometime.", 11 | "stories": "Go to Storybook", 12 | "repository_name": "Repository Name: {name}", 13 | "repository_full_name": "Repository full name: {fullName}", 14 | "repository_stars": "Repository stars: {stars}", 15 | "wednesday_solutions": "Wednesday Solutions", 16 | "you_are_awesome": "You are awesome", 17 | "recommended": "Recommended" 18 | } 19 | -------------------------------------------------------------------------------- /app/reducers.js: -------------------------------------------------------------------------------- 1 | /* 2 | Combine all reducers in this file and export the combined reducers. 3 | */ 4 | 5 | import { enableAllPlugins } from 'immer'; 6 | import { combineReducers } from 'redux'; 7 | 8 | import repos from './containers/Repos/reducer'; 9 | import info from './containers/Info/reducer'; 10 | 11 | enableAllPlugins(); 12 | 13 | /** 14 | * Merges the main reducer with the router state and dynamically injected reducers 15 | */ 16 | export default function createReducer(injectedReducer = {}) { 17 | const rootReducer = combineReducers({ 18 | ...injectedReducer, 19 | repos, 20 | info 21 | }); 22 | 23 | return rootReducer; 24 | } 25 | -------------------------------------------------------------------------------- /app/services/info.js: -------------------------------------------------------------------------------- 1 | import { generateApiClient } from '@utils/apiUtils'; 2 | const repoApi = generateApiClient('github'); 3 | 4 | export const getRepo = async (repo, owner = 'wednesday-solutions') => { 5 | if (!repo) { 6 | throw new Error('repo unavailable'); 7 | } 8 | const res = await repoApi.get(`/repos/${owner}/${repo}`); 9 | const getData = (response) => { 10 | if (!response.ok) { 11 | return []; 12 | } 13 | return response.data; 14 | }; 15 | return getData(res); 16 | }; 17 | -------------------------------------------------------------------------------- /app/services/repoApi.js: -------------------------------------------------------------------------------- 1 | import { generateApiClient } from '@utils/apiUtils'; 2 | const repoApi = generateApiClient('github'); 3 | 4 | export const getRepos = (repoName) => repoApi.get(`/search/repositories?q=${repoName}`); 5 | -------------------------------------------------------------------------------- /app/services/root.js: -------------------------------------------------------------------------------- 1 | import { generateApiClient } from '@utils/apiUtils'; 2 | const repoApi = generateApiClient('github'); 3 | 4 | export const getReccomendations = async () => { 5 | const res = await repoApi.get(`/orgs/wednesday-solutions/repos?type=public`); 6 | const getData = (response) => { 7 | if (!response.ok) { 8 | console.error(res.data); 9 | return []; 10 | } 11 | 12 | const recommendations = ['react-floki', 'nextjs-template']; 13 | return response.data.filter(({ name }) => recommendations.includes(name)).map(({ id, name }) => ({ id, name })); 14 | }; 15 | return getData(res); 16 | }; 17 | -------------------------------------------------------------------------------- /app/services/tests/info.test.js: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter' 2 | import { getApiClient } from '@utils/apiUtils' 3 | 4 | describe('info tests', () => { 5 | it('should ensure it throws err when ther is not any repo', () => { 6 | const { getRepo } = require('../info') 7 | const repoErr = getRepo() 8 | return expect(repoErr).rejects.toEqual(new Error('repo unavailable')) 9 | }) 10 | 11 | it('should ensure it returns data when response is ok', async () => { 12 | const { getRepo } = require('../info') 13 | const repo = 'am' 14 | const owner = 'wednesday' 15 | const mock = new MockAdapter(getApiClient().axiosInstance) 16 | const resData = ['as'] 17 | mock.onGet(`/repos/${owner}/${repo}`).reply(200, resData) 18 | const res = await getRepo(repo, owner) 19 | expect(res).toStrictEqual(resData) 20 | }) 21 | it('should ensure it returns an empty array when err coming in response', async () => { 22 | const { getRepo } = require('../info') 23 | const repo = 'am' 24 | const owner = 'wednesday' 25 | const mock = new MockAdapter(getApiClient().axiosInstance) 26 | const resData = {} 27 | mock.onGet(`/repos/${owner}/${repo}`).reply(404, resData) 28 | const res = await getRepo(repo, owner) 29 | expect(res).toStrictEqual([]) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /app/services/tests/repoApi.test.js: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter' 2 | import { getApiClient } from '@utils/apiUtils' 3 | import { getRepos } from '../repoApi' 4 | 5 | describe('RepoApi tests', () => { 6 | const repositoryName = 'mac' 7 | it('should make the api call to "/search/repositories?q="', async () => { 8 | const mock = new MockAdapter(getApiClient().axiosInstance) 9 | const data = [ 10 | { 11 | totalCount: 1, 12 | items: [{ repositoryName }] 13 | } 14 | ] 15 | mock.onGet(`/search/repositories?q=${repositoryName}`).reply(200, data) 16 | const res = await getRepos(repositoryName) 17 | expect(res.data).toEqual(data) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /app/services/tests/root.test.js: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter' 2 | import { getApiClient } from '@utils/apiUtils' 3 | 4 | describe('root tests', () => { 5 | it('should ensure it returns data when response is ok', async () => { 6 | const { getReccomendations } = require('../root') 7 | const mock = new MockAdapter(getApiClient().axiosInstance) 8 | const resData = [{ id: 1, name: 'react-floki' }] 9 | mock 10 | .onGet(`/orgs/wednesday-solutions/repos?type=public`) 11 | .reply(200, resData) 12 | const res = await getReccomendations() 13 | expect(res).toEqual(resData) 14 | }) 15 | 16 | it('should ensure it returns an empty array when err coming in response', async () => { 17 | const { getReccomendations } = require('../root') 18 | const mock = new MockAdapter(getApiClient().axiosInstance) 19 | const resData = { data: ['as'] } 20 | mock 21 | .onGet(`/orgs/wednesday-solutions/repos?type=public`) 22 | .reply(404, resData) 23 | const res = await getReccomendations() 24 | expect(res).toStrictEqual([]) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /app/tests/i18n.test.js: -------------------------------------------------------------------------------- 1 | import { formatTranslationMessages } from '../i18n' 2 | 3 | jest.mock('../lang/en.json', () => ({ 4 | message1: 'default message', 5 | message2: 'default message 2' 6 | })) 7 | 8 | const esTranslationMessages = { 9 | message1: 'mensaje predeterminado', 10 | message2: '' 11 | } 12 | 13 | describe('formatTranslationMessages tests', () => { 14 | it('should build only defaults when DEFAULT_LOCALE', () => { 15 | const result = formatTranslationMessages('en', { a: 'a' }) 16 | expect(result).toEqual({ a: 'a' }) 17 | }) 18 | 19 | it('should combine default locale and current locale when not DEFAULT_LOCALE', () => { 20 | const result = formatTranslationMessages('', esTranslationMessages) 21 | 22 | expect(result).toEqual({ 23 | message1: 'mensaje predeterminado', 24 | message2: 'default message 2' 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /app/themes/colors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the application's colors. 3 | * 4 | * Define color here instead of duplicating them throughout the components. 5 | * That allows to change them more easily later on. 6 | */ 7 | 8 | const primary = '#006ED6'; 9 | const text = '#000000'; 10 | const secondary = '#f8c49c'; 11 | const success = '#28a745'; 12 | const error = '#dc3545'; 13 | const transparent80 = 'rgba(0, 0, 0, 0.2)'; 14 | 15 | const colors = { 16 | transparent: 'rgba(0,0,0,0)', 17 | // Example colors: 18 | text, 19 | primary, 20 | secondary, 21 | success, 22 | error, 23 | transparent80, 24 | theme: { 25 | lightMode: { 26 | primary, 27 | secondary 28 | }, 29 | darkMode: { 30 | primary: secondary, 31 | secondary: primary 32 | } 33 | } 34 | }; 35 | module.exports = colors; 36 | -------------------------------------------------------------------------------- /app/themes/fonts.js: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | 3 | const regular = () => css` 4 | font-size: 1.125rem; 5 | `; 6 | const xRegular = () => css` 7 | font-size: 1.5rem; 8 | `; 9 | const small = () => css` 10 | font-size: 1rem; 11 | `; 12 | const xsmall = () => css` 13 | font-size: 0.875rem; 14 | `; 15 | 16 | const xxsmall = () => css` 17 | font-size: 0.75rem; 18 | `; 19 | 20 | const xxxsmall = () => css` 21 | font-size: 0.625rem; 22 | `; 23 | 24 | const big = () => css` 25 | font-size: 1.75rem; 26 | `; 27 | 28 | const large = () => css` 29 | font-size: 2.25rem; 30 | `; 31 | 32 | const extraLarge = () => css` 33 | font-size: 3rem; 34 | `; 35 | 36 | // weights 37 | const light = () => css` 38 | font-weight: 300; 39 | `; 40 | const bold = () => css` 41 | font-weight: 500; 42 | `; 43 | 44 | const normal = () => css` 45 | font-weight: normal; 46 | `; 47 | 48 | // styles 49 | const heading = () => css` 50 | ${large()} 51 | ${bold()} 52 | `; 53 | 54 | const subheading = () => css` 55 | ${big()} 56 | ${bold()} 57 | `; 58 | 59 | const standard = () => css` 60 | ${regular()} 61 | ${normal()} 62 | `; 63 | 64 | const subText = () => css` 65 | ${small()} 66 | ${normal()} 67 | `; 68 | 69 | /* eslint import/no-anonymous-default-export: [2, {"allowObject": true}] */ 70 | export default { 71 | size: { 72 | regular, 73 | small, 74 | big, 75 | large, 76 | extraLarge, 77 | xRegular, 78 | xsmall, 79 | xxsmall, 80 | xxxsmall 81 | }, 82 | style: { 83 | heading, 84 | subheading, 85 | standard, 86 | subText 87 | }, 88 | weights: { 89 | light, 90 | bold, 91 | normal 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /app/themes/index.js: -------------------------------------------------------------------------------- 1 | import colors from './colors'; 2 | import fonts from './fonts'; 3 | import media from './media'; 4 | import styles from './styles'; 5 | 6 | export { colors, fonts, media, styles }; 7 | -------------------------------------------------------------------------------- /app/themes/media.js: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | 3 | export const screenSizes = { 4 | extraLargeDesktop: 1920, 5 | largeDesktop: 1440, 6 | desktop: 1025, 7 | largeTablet: 865, 8 | tablet: 768, 9 | largeMobile: 480, 10 | mobile: 320, 11 | iPhonePlus: 400 12 | }; 13 | // iterate through sizes and create a media template 14 | export default Object.keys(screenSizes).reduce((acc, label) => { 15 | acc[label] = { 16 | min: (args) => css` 17 | @media (min-width: ${screenSizes[label] / 16}em) { 18 | ${css([args])}; 19 | } 20 | `, 21 | max: (args) => css` 22 | @media (max-width: ${screenSizes[label] / 16}em) { 23 | ${css([args])}; 24 | } 25 | ` 26 | }; 27 | return acc; 28 | }, {}); 29 | -------------------------------------------------------------------------------- /app/themes/styles.js: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | 3 | /** 4 | * A function for configuring css box-shadow. 5 | * @param hOffset 6 | * @param vOffset 7 | * @param blurRadius 8 | * @param color 9 | * @returns {[]|null|string|*} 10 | */ 11 | const boxShadow = (hOffset = '2px', vOffset = '2px', blurRadius = '2px', color) => css` 12 | box-shadow: ${hOffset} ${vOffset} ${blurRadius} ${color}; 13 | `; 14 | 15 | /** 16 | * A function for configuring css text-shadow. 17 | * 18 | * @param hOffset 19 | * @param vOffset 20 | * @param blurRadius 21 | * @param color 22 | * @returns {[]|null|string|*} 23 | */ 24 | const textShadow = (hOffset = '2px', vOffset = '2px', blurRadius = '2px', color) => css` 25 | text-shadow: ${hOffset} ${vOffset} ${blurRadius} ${color}; 26 | `; 27 | 28 | /** 29 | * A function that takes colors and assumes the top-bottom scenario. 30 | * 31 | * @param color1 32 | * @param color2 33 | * @returns {[]|null|string|*} 34 | */ 35 | const defaultLinearGradient = (color1, color2) => `linear-gradient(${color1}, ${color2})`; 36 | 37 | /* eslint import/no-anonymous-default-export: [2, {"allowObject": true}] */ 38 | export default { 39 | boxShadow, 40 | defaultLinearGradient, 41 | textShadow 42 | }; 43 | -------------------------------------------------------------------------------- /app/themes/tests/colors.test.js: -------------------------------------------------------------------------------- 1 | import colors from '../colors' 2 | 3 | describe('colors', () => { 4 | it('should have the correct font-size', () => { 5 | expect(colors.theme.lightMode).toEqual({ 6 | primary: colors.primary, 7 | secondary: colors.secondary 8 | }) 9 | expect(colors.theme.darkMode).toEqual({ 10 | primary: colors.secondary, 11 | secondary: colors.primary 12 | }) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /app/themes/tests/fonts.test.js: -------------------------------------------------------------------------------- 1 | import fonts from '../fonts' 2 | 3 | describe('fonts', () => { 4 | it('should have the correct font-size', () => { 5 | expect( 6 | fonts.size 7 | .small() 8 | .styles.replace(/[\r\n\s]+/gm, '') 9 | .trim() 10 | ).toBe('font-size:1rem;') 11 | expect( 12 | fonts.size 13 | .regular() 14 | .styles.replace(/[\r\n\s]+/gm, '') 15 | .trim() 16 | ).toBe('font-size:1.125rem;') 17 | expect( 18 | fonts.size 19 | .big() 20 | .styles.replace(/[\r\n\s]+/gm, '') 21 | .trim() 22 | ).toBe('font-size:1.75rem;') 23 | expect( 24 | fonts.size 25 | .large() 26 | .styles.replace(/[\r\n\s]+/gm, '') 27 | .trim() 28 | ).toBe('font-size:2.25rem;') 29 | expect( 30 | fonts.size 31 | .extraLarge() 32 | .styles.replace(/[\r\n\s]+/gm, '') 33 | .trim() 34 | ).toBe('font-size:3rem;') 35 | }) 36 | it('should have the correct font-weight', () => { 37 | expect( 38 | fonts.weights 39 | .light() 40 | .styles.replace(/[\r\n\s]+/gm, '') 41 | .trim() 42 | ).toBe('font-weight:300;') 43 | expect( 44 | fonts.weights 45 | .bold() 46 | .styles.replace(/[\r\n\s]+/gm, '') 47 | .trim() 48 | ).toBe('font-weight:500;') 49 | expect( 50 | fonts.weights 51 | .normal() 52 | .styles.replace(/[\r\n\s]+/gm, '') 53 | .trim() 54 | ).toBe('font-weight:normal;') 55 | }) 56 | 57 | it('should have the correct font-weight and font-size', () => { 58 | expect( 59 | fonts.style 60 | .heading() 61 | .styles.replace(/[\r\n\s]+/gm, '') 62 | .trim() 63 | ).toBe('font-size:2.25rem;;font-weight:500;;') 64 | expect( 65 | fonts.style 66 | .subheading() 67 | .styles.replace(/[\r\n\s]+/gm, '') 68 | .trim() 69 | ).toEqual('font-size:1.75rem;;font-weight:500;;') 70 | expect( 71 | fonts.style 72 | .standard() 73 | .styles.replace(/[\r\n\s]+/gm, '') 74 | .trim() 75 | ).toEqual('font-size:1.125rem;;font-weight:normal;;') 76 | expect( 77 | fonts.style 78 | .subText() 79 | .styles.replace(/[\r\n\s]+/gm, '') 80 | .trim() 81 | ).toEqual('font-size:1rem;;font-weight:normal;;') 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /app/themes/tests/media.test.js: -------------------------------------------------------------------------------- 1 | import media from '../media' 2 | 3 | describe('styles', () => { 4 | it('should return correct media query according to screen size', () => { 5 | expect( 6 | media.largeDesktop 7 | .max(`background: gray`) 8 | .styles.replace(/[\r\n\s]+/gm, '') 9 | ).toBe('@media(max-width:90em){background:gray;;;}') 10 | }) 11 | 12 | it('should return correct media according to min or max', () => { 13 | expect( 14 | media.desktop 15 | .min(`background: gray`) 16 | .styles.replace(/[\r\n\s]+/gm, '') 17 | .trim() 18 | ).toBe('@media(min-width:64.0625em){background:gray;;;}') 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /app/themes/tests/styles.test.js: -------------------------------------------------------------------------------- 1 | import styles from '../styles' 2 | import { colors } from '@themes' 3 | 4 | describe('styles', () => { 5 | it('should have the correct linear-gradient string', () => { 6 | expect(styles.defaultLinearGradient('red', 'orange')).toBe( 7 | 'linear-gradient(red, orange)' 8 | ) 9 | }) 10 | 11 | it('should have the correct box-shadow', () => { 12 | expect( 13 | styles 14 | .boxShadow(colors.transparent80) 15 | .styles.replace(/[\r\n\s]+/gm, '') 16 | .trim() 17 | ).toBe('box-shadow:rgba(0,0,0,0.2)2px2px;') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /app/utils/apiUtils.js: -------------------------------------------------------------------------------- 1 | import { create } from 'apisauce'; 2 | import snakeCase from 'lodash/snakeCase'; 3 | import camelCase from 'lodash/camelCase'; 4 | import { mapKeysDeep } from './index'; 5 | 6 | const apiClients = { 7 | github: null, 8 | default: null 9 | }; 10 | 11 | export const getApiClient = (type = 'github') => apiClients[type]; 12 | export const generateApiClient = (type = 'github') => { 13 | switch (type) { 14 | case 'github': 15 | apiClients[type] = createApiClientWithTransForm(process.env.NEXT_PUBLIC_GITHUB_URL); 16 | return apiClients[type]; 17 | default: 18 | apiClients.default = createApiClientWithTransForm(process.env.NEXT_PUBLIC_GITHUB_URL); 19 | return apiClients.default; 20 | } 21 | }; 22 | 23 | export const createApiClientWithTransForm = (baseURL) => { 24 | const api = create({ 25 | baseURL, 26 | headers: { 'Content-Type': 'application/json' } 27 | }); 28 | api.addResponseTransform((response) => { 29 | const { ok, data } = response; 30 | if (ok && data) { 31 | response.data = mapKeysDeep(data, (keys) => camelCase(keys)); 32 | } 33 | return response; 34 | }); 35 | 36 | api.addRequestTransform((request) => { 37 | const { data } = request; 38 | if (data) { 39 | request.data = mapKeysDeep(data, (keys) => snakeCase(keys)); 40 | } 41 | return request; 42 | }); 43 | return api; 44 | }; 45 | -------------------------------------------------------------------------------- /app/utils/checkStore.js: -------------------------------------------------------------------------------- 1 | import { conformsTo, isFunction, isObject } from 'lodash'; 2 | import invariant from 'invariant'; 3 | 4 | /** 5 | * Validate the shape of redux store 6 | */ 7 | export default function checkStore(store) { 8 | const shape = { 9 | dispatch: isFunction, 10 | subscribe: isFunction, 11 | getState: isFunction, 12 | replaceReducer: isFunction, 13 | runSaga: isFunction, 14 | injectedReducers: isObject, 15 | injectedSagas: isObject 16 | }; 17 | invariant(conformsTo(store, shape), '(app/utils...) injectors: Expected a valid redux store'); 18 | } 19 | -------------------------------------------------------------------------------- /app/utils/constants.js: -------------------------------------------------------------------------------- 1 | export const RESTART_ON_REMOUNT = '@@saga-injector/restart-on-remount'; 2 | export const DAEMON = '@@saga-injector/daemon'; 3 | export const ONCE_TILL_UNMOUNT = '@@saga-injector/once-till-unmount'; 4 | export const SOMETHING_WENT_WRONG = 'Something went wrong.'; 5 | 6 | export const ERRORS = { 7 | INSUFFICIENT_INFO: 'Insufficient Info' 8 | }; 9 | 10 | export const SCREEN_BREAK_POINTS = { 11 | mobile: 320, 12 | tablet: 768, 13 | desktop: 992 14 | }; 15 | -------------------------------------------------------------------------------- /app/utils/index.js: -------------------------------------------------------------------------------- 1 | import pickBy from 'lodash/pickBy'; 2 | import { screenSizes } from '@themes/media'; 3 | 4 | /** 5 | * Get query string value 6 | * @param {array} keys - The keys to get the value of 7 | * @returns {object} - The query string value 8 | */ 9 | export function getQueryStringValue(keys) { 10 | const queryString = {}; 11 | try { 12 | keys.forEach((key) => { 13 | queryString[key] = decodeURIComponent( 14 | window.location.search.replace( 15 | new RegExp(`^(?:.*[&\\?]${encodeURIComponent(key).replace(/[.+*]/g, '\\$&')}(?:\\=([^&]*))?)?.*$`, 'i'), 16 | '$1' 17 | ) 18 | ); 19 | }); 20 | 21 | return pickBy(queryString); 22 | } catch (error) { 23 | return null; 24 | } 25 | } 26 | 27 | /* eslint-disable complexity */ 28 | export const mapKeysDeep = (obj, fn) => 29 | Array.isArray(obj) 30 | ? obj.map((val) => mapKeysDeep(val, fn)) 31 | : typeof obj === 'object' 32 | ? Object.keys(obj).reduce((acc, current) => { 33 | const key = fn(current); 34 | const val = obj[current]; 35 | acc[key] = val !== null && typeof val === 'object' ? mapKeysDeep(val, fn) : val; 36 | return acc; 37 | }, {}) 38 | : obj; 39 | 40 | export const setDeviceType = (width = document.body.clientWidth) => { 41 | if (width >= screenSizes.mobile && width < screenSizes.tablet) { 42 | return 'mobile'; 43 | } else if (width >= screenSizes.tablet && width < screenSizes.desktop) { 44 | return 'tablet'; 45 | } 46 | return 'desktop'; 47 | }; 48 | 49 | export const getDeviceType = (device) => (device || setDeviceType()).toUpperCase(); 50 | -------------------------------------------------------------------------------- /app/utils/injectSaga.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import hoistNonReactStatics from 'hoist-non-react-statics'; 3 | import { ReactReduxContext } from 'react-redux'; 4 | 5 | import getInjectors from './sagaInjectors'; 6 | 7 | /** 8 | * Dynamically injects a saga, passes component's props as saga arguments 9 | * 10 | * @param {string} key A key of the saga 11 | * @param {function} saga A root saga that will be injected 12 | * @param {string} [mode] By default (constants.DAEMON) the saga will be started 13 | * on component mount and never canceled or started again. Another two options: 14 | * - constants.RESTART_ON_REMOUNT — the saga will be started on component mount and 15 | * cancelled with `task.cancel()` on component unmount for improved performance, 16 | * - constants.ONCE_TILL_UNMOUNT — behaves like 'RESTART_ON_REMOUNT' but never runs it again. 17 | * 18 | */ 19 | export default ({ key, saga, mode }) => 20 | (WrappedComponent) => { 21 | class InjectSaga extends React.Component { 22 | static WrappedComponent = WrappedComponent; 23 | 24 | static contextType = ReactReduxContext; 25 | 26 | static displayName = `withSaga(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; 27 | 28 | constructor(props, context) { 29 | super(props, context); 30 | 31 | this.injectors = getInjectors(context.store); 32 | 33 | this.injectors.injectSaga(key, { saga, mode }, this.props); 34 | } 35 | 36 | componentWillUnmount() { 37 | this.injectors.ejectSaga(key); 38 | } 39 | 40 | render() { 41 | return <WrappedComponent {...this.props} />; 42 | } 43 | } 44 | 45 | return hoistNonReactStatics(InjectSaga, WrappedComponent); 46 | }; 47 | 48 | const useInjectSaga = ({ key, saga, mode }) => { 49 | const context = React.useContext(ReactReduxContext); 50 | React.useEffect(() => { 51 | const injectors = getInjectors(context.store); 52 | injectors.injectSaga(key, { saga, mode }); 53 | return () => { 54 | injectors.ejectSaga(key); 55 | }; 56 | }, []); 57 | }; 58 | 59 | export { useInjectSaga }; 60 | -------------------------------------------------------------------------------- /app/utils/reducer.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | export const PAYLOAD = { 4 | DATA: 'data', 5 | LOADING: 'loading', 6 | ERROR: 'error' 7 | }; 8 | export const startLoading = (draft) => { 9 | draft[PAYLOAD.LOADING] = true; 10 | }; 11 | 12 | export const stopLoading = (draft) => { 13 | draft[PAYLOAD.LOADING] = false; 14 | }; 15 | 16 | export const setData = (draft, action, key = PAYLOAD.DATA, defaultValue = null) => { 17 | draft[key] = get(action, key, defaultValue); 18 | }; 19 | 20 | export const setError = (draft, action) => { 21 | setData(draft, action, PAYLOAD.ERROR, 'something_went_wrong'); 22 | }; 23 | -------------------------------------------------------------------------------- /app/utils/sagaInjectors.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { isEmpty, isFunction, isString, conformsTo } from 'lodash'; 3 | 4 | import checkStore from './checkStore'; 5 | import { DAEMON, ONCE_TILL_UNMOUNT, RESTART_ON_REMOUNT } from './constants'; 6 | 7 | const allowedModes = [RESTART_ON_REMOUNT, DAEMON, ONCE_TILL_UNMOUNT]; 8 | 9 | const checkKey = (key) => 10 | invariant(isString(key) && !isEmpty(key), '(app/utils...) injectSaga: Expected `key` to be a non empty string'); 11 | 12 | const checkDescriptor = (descriptor) => { 13 | const shape = { 14 | saga: isFunction, 15 | mode: (mode) => isString(mode) && allowedModes.includes(mode) 16 | }; 17 | invariant(conformsTo(descriptor, shape), '(app/utils...) injectSaga: Expected a valid saga descriptor'); 18 | }; 19 | 20 | /** 21 | * Validate the saga, mode and key 22 | * @param {object} descriptor The saga descriptor 23 | * @param {string} key The saga key 24 | * @param {object} saga The saga 25 | */ 26 | export function injectSagaFactory(store, isValid) { 27 | const updateHasSagaInDevelopment = (hasSaga, key, saga) => { 28 | const oldDescriptor = store.injectedSagas[key]; 29 | // enable hot reloading of daemon and once-till-unmount sagas 30 | if (hasSaga && oldDescriptor.saga !== saga) { 31 | oldDescriptor.task.cancel(); 32 | return false; 33 | } 34 | return hasSaga; 35 | }; 36 | 37 | const updateStoreInjectors = (newDescriptor, saga, key, args) => { 38 | store.injectedSagas[key] = { 39 | ...newDescriptor, 40 | task: store.runSaga(saga, args) 41 | }; 42 | }; 43 | 44 | const checkAndUpdateStoreInjectors = (hasSaga, key, newDescriptor, args) => { 45 | if (!hasSaga || (hasSaga && newDescriptor.mode !== DAEMON && newDescriptor.mode !== ONCE_TILL_UNMOUNT)) { 46 | updateStoreInjectors(newDescriptor, newDescriptor.saga, key, args); 47 | } 48 | }; 49 | 50 | return function injectSaga(key, descriptor = {}, args) { 51 | if (!isValid) { 52 | checkStore(store); 53 | } 54 | 55 | const newDescriptor = { 56 | ...descriptor, 57 | mode: descriptor.mode || DAEMON 58 | }; 59 | 60 | checkKey(key); 61 | checkDescriptor(newDescriptor); 62 | 63 | let hasSaga = Reflect.has(store.injectedSagas, key); 64 | 65 | if (process.env.NODE_ENV !== 'production') { 66 | hasSaga = updateHasSagaInDevelopment(hasSaga, key, newDescriptor.saga); 67 | } 68 | 69 | checkAndUpdateStoreInjectors(hasSaga, key, newDescriptor, args); 70 | 71 | return key; 72 | }; 73 | } 74 | 75 | /** 76 | * Eject the saga 77 | * @param {string} key The saga key 78 | * @param {object} store The redux store 79 | * @param {boolean} isValid If the store is valid 80 | */ 81 | export function ejectSagaFactory(store, isValid) { 82 | /** 83 | * Clean up the store 84 | * @param {string} key The saga key 85 | * @returns {void} 86 | */ 87 | function updateStoreSaga(key) { 88 | // Clean up in production; in development we need `descriptor.saga` for hot reloading 89 | if (process.env.NODE_ENV === 'production') { 90 | // Need some value to be able to detect `ONCE_TILL_UNMOUNT` sagas in `injectSaga` 91 | store.injectedSagas[key] = 'done'; 92 | } 93 | } 94 | 95 | return function ejectSaga(key) { 96 | if (!isValid) { 97 | checkStore(store); 98 | } 99 | 100 | checkKey(key); 101 | 102 | if (Reflect.has(store.injectedSagas, key)) { 103 | const descriptor = store.injectedSagas[key]; 104 | if (descriptor.mode && descriptor.mode === DAEMON) { 105 | return; 106 | } 107 | 108 | descriptor.task.cancel(); 109 | updateStoreSaga(key); 110 | } 111 | }; 112 | } 113 | 114 | /** 115 | * Get the injectors 116 | * @param {object} store The redux store 117 | */ 118 | export default function getInjectors(store) { 119 | checkStore(store); 120 | return { 121 | injectSaga: injectSagaFactory(store, true), 122 | ejectSaga: ejectSagaFactory(store, true) 123 | }; 124 | } 125 | -------------------------------------------------------------------------------- /app/utils/testUtils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { ThemeProvider } from 'styled-components'; 4 | import { IntlProvider } from 'react-intl'; 5 | import { Provider } from 'react-redux'; 6 | import configureStore from '../configureStore'; 7 | import colors from '@themes/colors'; 8 | import { translationMessages, DEFAULT_LOCALE } from '../i18n'; 9 | 10 | export const getComponentStyles = (Component, props = {}) => { 11 | renderProvider(Component(props)); 12 | const { styledComponentId } = Component(props).type; 13 | const componentRoots = document.getElementsByClassName(styledComponentId); 14 | 15 | return window.getComputedStyle(componentRoots[0])._values; 16 | }; 17 | 18 | export const renderProvider = (children) => { 19 | return render( 20 | <IntlProvider locale={DEFAULT_LOCALE} key={DEFAULT_LOCALE} messages={translationMessages[DEFAULT_LOCALE]}> 21 | <ThemeProvider theme={colors}>{children}</ThemeProvider> 22 | </IntlProvider> 23 | ); 24 | }; 25 | export const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 26 | export const apiResponseGenerator = (ok, data) => ({ 27 | ok, 28 | data 29 | }); 30 | 31 | export const renderStoreProvider = (children) => { 32 | const store = configureStore({}); 33 | return render( 34 | <Provider store={store}> 35 | <IntlProvider locale={DEFAULT_LOCALE} key={DEFAULT_LOCALE} messages={translationMessages[DEFAULT_LOCALE]}> 36 | <ThemeProvider theme={colors}>{children}</ThemeProvider> 37 | </IntlProvider> 38 | </Provider> 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /app/utils/tests/checkStore.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test injectors 3 | */ 4 | 5 | import checkStore from '../checkStore' 6 | 7 | describe('checkStore', () => { 8 | let store 9 | 10 | beforeEach(() => { 11 | store = { 12 | dispatch: () => {}, 13 | subscribe: () => {}, 14 | getState: () => {}, 15 | replaceReducer: () => {}, 16 | runSaga: () => {}, 17 | injectedReducers: {}, 18 | injectedSagas: {} 19 | } 20 | }) 21 | 22 | it('should not throw if passed valid store shape', () => { 23 | expect(() => checkStore(store)).not.toThrow() 24 | }) 25 | 26 | it('should throw if passed invalid store shape', () => { 27 | expect(() => checkStore({})).toThrow() 28 | expect(() => checkStore({ ...store, injectedSagas: null })).toThrow() 29 | expect(() => checkStore({ ...store, injectedReducers: null })).toThrow() 30 | expect(() => checkStore({ ...store, runSaga: null })).toThrow() 31 | expect(() => checkStore({ ...store, replaceReducer: null })).toThrow() 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /app/utils/tests/index.test.js: -------------------------------------------------------------------------------- 1 | import { getQueryStringValue, getDeviceType, setDeviceType } from '../index' 2 | describe('Utils', () => { 3 | test('should return not return anything', () => { 4 | Object.defineProperty(window, 'location', { 5 | value: { 6 | writable: false, 7 | search: '' 8 | } 9 | }) 10 | const result = getQueryStringValue(['campaign_uuid']) 11 | expect(result).toEqual({}) 12 | }) 13 | 14 | test('should return query string value when campaign_uuid is provided', () => { 15 | delete global.window.location 16 | Object.defineProperty(window, 'location', { 17 | value: { 18 | writable: false, 19 | search: '?campaign_uuid=784c7251-a124-4257-b47a-6b1e94dc51bx' 20 | } 21 | }) 22 | const result = getQueryStringValue(['campaign_uuid']) 23 | expect(result).toEqual({ 24 | campaign_uuid: '784c7251-a124-4257-b47a-6b1e94dc51bx' 25 | }) 26 | }) 27 | }) 28 | 29 | describe('getDeviceType', () => { 30 | test('should return MOBILE for device mobile', () => { 31 | const result = getDeviceType('mobile') 32 | expect(result).toEqual('MOBILE') 33 | }) 34 | 35 | test('should return DESKTOP by default', () => { 36 | const result = getDeviceType('desktop') 37 | expect(result).toEqual('DESKTOP') 38 | }) 39 | test('should return TABLET for device tablet', () => { 40 | const result = getDeviceType('tablet') 41 | expect(result).toEqual('TABLET') 42 | }) 43 | 44 | test('should return DESKTOP if no device', () => { 45 | jest.spyOn(window.screen, 'width', 'get').mockReturnValue(1280) 46 | setDeviceType() 47 | const result = getDeviceType() 48 | expect(result).toEqual('DESKTOP') 49 | }) 50 | }) 51 | 52 | describe('setDeviceType', () => { 53 | test('should return mobile for width 360', () => { 54 | const result = setDeviceType(360) 55 | expect(result).toEqual('mobile') 56 | }) 57 | 58 | test('should return desktop for width 1280', () => { 59 | const result = setDeviceType(1280) 60 | expect(result).toEqual('desktop') 61 | }) 62 | test('should return tablet for width 800', () => { 63 | const result = setDeviceType(800) 64 | expect(result).toEqual('tablet') 65 | }) 66 | test('should return tablet for mocked screen width', () => { 67 | jest.spyOn(window.screen, 'width', 'get').mockReturnValue(1000) 68 | const result = setDeviceType() 69 | expect(result).toEqual('desktop') 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /app/utils/tests/injectSaga.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-import-assign */ 2 | /* eslint-disable react/display-name */ 3 | /** 4 | * Test injectors 5 | */ 6 | 7 | import { put } from 'redux-saga/effects' 8 | import renderer from 'react-test-renderer' 9 | import { render } from '@testing-library/react' 10 | import React from 'react' 11 | import { Provider } from 'react-redux' 12 | 13 | import configureStore from '../../configureStore' 14 | import injectSaga, { useInjectSaga } from '../injectSaga' 15 | import * as sagaInjectors from '../sagaInjectors' 16 | 17 | // Fixtures 18 | const Component = () => null 19 | 20 | function* testSaga() { 21 | yield put({ type: 'TEST', payload: 'yup' }) 22 | } 23 | 24 | describe('injectSaga decorator', () => { 25 | let store 26 | let injectors 27 | let ComponentWithSaga 28 | 29 | beforeAll(() => { 30 | sagaInjectors.default = jest.fn().mockImplementation(() => injectors) 31 | }) 32 | 33 | beforeEach(() => { 34 | store = configureStore({}) 35 | injectors = { 36 | injectSaga: jest.fn(), 37 | ejectSaga: jest.fn() 38 | } 39 | ComponentWithSaga = injectSaga({ 40 | key: 'test', 41 | saga: testSaga, 42 | mode: 'testMode' 43 | })(Component) 44 | sagaInjectors.default.mockClear() 45 | }) 46 | 47 | it('should inject given saga, mode, and props', () => { 48 | const props = { test: 'test' } 49 | renderer.create( 50 | <Provider store={store}> 51 | <ComponentWithSaga {...props} /> 52 | </Provider> 53 | ) 54 | 55 | expect(injectors.injectSaga).toHaveBeenCalledTimes(1) 56 | expect(injectors.injectSaga).toHaveBeenCalledWith( 57 | 'test', 58 | { saga: testSaga, mode: 'testMode' }, 59 | props 60 | ) 61 | }) 62 | 63 | it('should eject on unmount with a correct saga key', () => { 64 | const props = { test: 'test' } 65 | const renderedComponent = renderer.create( 66 | <Provider store={store}> 67 | <ComponentWithSaga {...props} /> 68 | </Provider> 69 | ) 70 | renderedComponent.unmount() 71 | 72 | expect(injectors.ejectSaga).toHaveBeenCalledTimes(1) 73 | expect(injectors.ejectSaga).toHaveBeenCalledWith('test') 74 | }) 75 | 76 | it('should set a correct display name', () => { 77 | expect(ComponentWithSaga.displayName).toBe('withSaga(Component)') 78 | expect( 79 | injectSaga({ key: 'test', saga: testSaga })(() => null).displayName 80 | ).toBe('withSaga(Component)') 81 | }) 82 | }) 83 | 84 | describe('useInjectSaga hook', () => { 85 | let store 86 | let injectors 87 | let ComponentWithSaga 88 | 89 | beforeAll(() => { 90 | sagaInjectors.default = jest.fn().mockImplementation(() => injectors) 91 | }) 92 | 93 | beforeEach(() => { 94 | store = configureStore({}) 95 | injectors = { 96 | injectSaga: jest.fn(), 97 | ejectSaga: jest.fn() 98 | } 99 | ComponentWithSaga = () => { 100 | useInjectSaga({ 101 | key: 'test', 102 | saga: testSaga, 103 | mode: 'testMode' 104 | }) 105 | return null 106 | } 107 | sagaInjectors.default.mockClear() 108 | }) 109 | 110 | it('should inject given saga and mode', () => { 111 | const props = { test: 'test' } 112 | render( 113 | <Provider store={store}> 114 | <ComponentWithSaga {...props} /> 115 | </Provider> 116 | ) 117 | 118 | expect(injectors.injectSaga).toHaveBeenCalledTimes(1) 119 | expect(injectors.injectSaga).toHaveBeenCalledWith('test', { 120 | saga: testSaga, 121 | mode: 'testMode' 122 | }) 123 | }) 124 | 125 | it('should eject on unmount with a correct saga key', () => { 126 | const props = { test: 'test' } 127 | const { unmount } = render( 128 | <Provider store={store}> 129 | <ComponentWithSaga {...props} /> 130 | </Provider> 131 | ) 132 | unmount() 133 | 134 | expect(injectors.ejectSaga).toHaveBeenCalledTimes(1) 135 | expect(injectors.ejectSaga).toHaveBeenCalledWith('test') 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /app/utils/tests/sagaInjectors.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test injectors 3 | */ 4 | import { put } from 'redux-saga/effects' 5 | 6 | import configureStore from '../../configureStore' 7 | import getInjectors, { 8 | injectSagaFactory, 9 | ejectSagaFactory 10 | } from '../sagaInjectors' 11 | import { DAEMON, ONCE_TILL_UNMOUNT, RESTART_ON_REMOUNT } from '../constants' 12 | 13 | function* testSaga() { 14 | yield put({ type: 'TEST', payload: 'yup' }) 15 | } 16 | 17 | describe('injectors', () => { 18 | const originalNodeEnv = process.env.NODE_ENV 19 | let store 20 | let injectSaga 21 | let ejectSaga 22 | 23 | describe('getInjectors', () => { 24 | beforeEach(() => { 25 | store = configureStore({}) 26 | }) 27 | 28 | it('should return injectors', () => { 29 | expect(getInjectors(store)).toEqual( 30 | expect.objectContaining({ 31 | injectSaga: expect.any(Function), 32 | ejectSaga: expect.any(Function) 33 | }) 34 | ) 35 | }) 36 | 37 | it('should throw if passed invalid store shape', () => { 38 | Reflect.deleteProperty(store, 'dispatch') 39 | 40 | expect(() => getInjectors(store)).toThrow() 41 | }) 42 | }) 43 | 44 | describe('ejectSaga helper', () => { 45 | beforeEach(() => { 46 | store = configureStore({}) 47 | injectSaga = injectSagaFactory(store, true) 48 | ejectSaga = ejectSagaFactory(store, true) 49 | }) 50 | 51 | it('should check a store if the second argument is falsy', () => { 52 | const eject = ejectSagaFactory({}) 53 | 54 | expect(() => eject('test')).toThrow() 55 | }) 56 | 57 | it('should not check a store if the second argument is true', () => { 58 | Reflect.deleteProperty(store, 'dispatch') 59 | injectSaga('test', { saga: testSaga }) 60 | 61 | expect(() => ejectSaga('test')).not.toThrow() 62 | }) 63 | 64 | it("should validate saga's key", () => { 65 | expect(() => ejectSaga('')).toThrow() 66 | expect(() => ejectSaga(1)).toThrow() 67 | }) 68 | 69 | it('should cancel a saga in RESTART_ON_REMOUNT mode', () => { 70 | const cancel = jest.fn() 71 | store.injectedSagas.test = { task: { cancel }, mode: RESTART_ON_REMOUNT } 72 | ejectSaga('test') 73 | 74 | expect(cancel).toHaveBeenCalled() 75 | }) 76 | 77 | it('should not cancel a daemon saga', () => { 78 | const cancel = jest.fn() 79 | store.injectedSagas.test = { task: { cancel }, mode: DAEMON } 80 | ejectSaga('test') 81 | 82 | expect(cancel).not.toHaveBeenCalled() 83 | }) 84 | 85 | it('should ignore saga that was not previously injected', () => { 86 | expect(() => ejectSaga('test')).not.toThrow() 87 | }) 88 | 89 | it("should remove non daemon saga's descriptor in production", () => { 90 | process.env.NODE_ENV = 'production' 91 | injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT }) 92 | injectSaga('test1', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }) 93 | 94 | ejectSaga('test') 95 | ejectSaga('test1') 96 | 97 | expect(store.injectedSagas.test).toBe('done') 98 | expect(store.injectedSagas.test1).toBe('done') 99 | process.env.NODE_ENV = originalNodeEnv 100 | }) 101 | 102 | it("should not remove daemon saga's descriptor in production", () => { 103 | process.env.NODE_ENV = 'production' 104 | injectSaga('test', { saga: testSaga, mode: DAEMON }) 105 | ejectSaga('test') 106 | 107 | expect(store.injectedSagas.test.saga).toBe(testSaga) 108 | process.env.NODE_ENV = originalNodeEnv 109 | }) 110 | 111 | it("should not remove daemon saga's descriptor in development", () => { 112 | injectSaga('test', { saga: testSaga, mode: DAEMON }) 113 | ejectSaga('test') 114 | 115 | expect(store.injectedSagas.test.saga).toBe(testSaga) 116 | }) 117 | }) 118 | 119 | describe('injectSaga helper', () => { 120 | beforeEach(() => { 121 | store = configureStore({}) 122 | injectSaga = injectSagaFactory(store, true) 123 | ejectSaga = ejectSagaFactory(store, true) 124 | }) 125 | 126 | it('should check a store if the second argument is falsy', () => { 127 | const inject = injectSagaFactory({}) 128 | 129 | expect(() => inject('test', testSaga)).toThrow() 130 | }) 131 | 132 | it('should not check a store if the second argument is true', () => { 133 | Reflect.deleteProperty(store, 'dispatch') 134 | 135 | expect(() => injectSaga('test', { saga: testSaga })).not.toThrow() 136 | }) 137 | 138 | it("should validate saga's key", () => { 139 | expect(() => injectSaga('', { saga: testSaga })).toThrow() 140 | expect(() => injectSaga(1, { saga: testSaga })).toThrow() 141 | }) 142 | 143 | it("should validate saga's descriptor", () => { 144 | expect(() => injectSaga('test')).toThrow() 145 | expect(() => injectSaga('test', { saga: 1 })).toThrow() 146 | expect(() => 147 | injectSaga('test', { saga: testSaga, mode: 'testMode' }) 148 | ).toThrow() 149 | expect(() => injectSaga('test', { saga: testSaga, mode: 1 })).toThrow() 150 | expect(() => 151 | injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT }) 152 | ).not.toThrow() 153 | expect(() => 154 | injectSaga('test', { saga: testSaga, mode: DAEMON }) 155 | ).not.toThrow() 156 | expect(() => 157 | injectSaga('test', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }) 158 | ).not.toThrow() 159 | }) 160 | 161 | it('should pass args to saga.run', () => { 162 | const args = {} 163 | store.runSaga = jest.fn() 164 | injectSaga('test', { saga: testSaga }, args) 165 | 166 | expect(store.runSaga).toHaveBeenCalledWith(testSaga, args) 167 | }) 168 | 169 | it('should not start daemon and once-till-unmount sagas if were started before', () => { 170 | store.runSaga = jest.fn() 171 | 172 | injectSaga('test1', { saga: testSaga, mode: DAEMON }) 173 | injectSaga('test1', { saga: testSaga, mode: DAEMON }) 174 | injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }) 175 | injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }) 176 | 177 | expect(store.runSaga).toHaveBeenCalledTimes(2) 178 | }) 179 | 180 | it('should start any saga that was not started before', () => { 181 | store.runSaga = jest.fn() 182 | 183 | injectSaga('test1', { saga: testSaga }) 184 | injectSaga('test2', { saga: testSaga, mode: DAEMON }) 185 | injectSaga('test3', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }) 186 | 187 | expect(store.runSaga).toHaveBeenCalledTimes(3) 188 | }) 189 | 190 | it('should restart a saga if different implementation for hot reloading', () => { 191 | const cancel = jest.fn() 192 | store.injectedSagas.test = { saga: testSaga, task: { cancel } } 193 | store.runSaga = jest.fn() 194 | 195 | function* testSaga1() { 196 | yield put({ type: 'TEST', payload: 'yup' }) 197 | } 198 | 199 | injectSaga('test', { saga: testSaga1 }) 200 | 201 | expect(cancel).toHaveBeenCalledTimes(1) 202 | expect(store.runSaga).toHaveBeenCalledWith(testSaga1, undefined) 203 | }) 204 | 205 | it('should not cancel saga if different implementation in production', () => { 206 | process.env.NODE_ENV = 'production' 207 | const cancel = jest.fn() 208 | store.injectedSagas.test = { 209 | saga: testSaga, 210 | task: { cancel }, 211 | mode: RESTART_ON_REMOUNT 212 | } 213 | 214 | function* testSaga1() { 215 | yield put({ type: 'TEST', payload: 'yup' }) 216 | } 217 | 218 | injectSaga('test', { saga: testSaga1, mode: DAEMON }) 219 | 220 | expect(cancel).toHaveBeenCalledTimes(0) 221 | process.env.NODE_ENV = originalNodeEnv 222 | }) 223 | 224 | it('should save an entire descriptor in the saga registry', () => { 225 | injectSaga('test', { saga: testSaga, foo: 'bar' }) 226 | expect(store.injectedSagas.test.foo).toBe('bar') 227 | }) 228 | }) 229 | }) 230 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process() { 3 | return 'module.exports = {};'; 4 | }, 5 | getCacheKey() { 6 | return 'cssTransform'; 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /config/jest/image.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | process(_src, filename) { 5 | let alias; 6 | try { 7 | alias = 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';'; 8 | } catch (error) { 9 | alias = 'Image'; 10 | } 11 | return alias; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /environments/.env.development: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GITHUB_URL=https://api.github.com/ 2 | -------------------------------------------------------------------------------- /environments/.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GITHUB_URL=https://api.github.com/ 2 | -------------------------------------------------------------------------------- /environments/.env.qa: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GITHUB_URL=https://api.github.com/ 2 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import { includeIgnoreFile } from '@eslint/compat'; 3 | import path from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import js from '@eslint/js'; 6 | import { FlatCompat } from '@eslint/eslintrc'; 7 | import fs from 'fs'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const gitignorePath = path.resolve(__dirname, '.gitignore'); 12 | 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all 17 | }); 18 | 19 | const prettierOptions = JSON.parse(fs.readFileSync(path.resolve(__dirname, '.prettierrc'), 'utf8')); 20 | 21 | export default [ 22 | ...compat.extends( 23 | 'eslint:recommended', 24 | 'plugin:react/recommended', 25 | 'plugin:jest/recommended', 26 | 'plugin:prettier/recommended', 27 | 'prettier', 28 | 'prettier-standard' 29 | ), 30 | 31 | includeIgnoreFile(gitignorePath), 32 | 33 | { 34 | languageOptions: { 35 | globals: { 36 | ...globals.browser, 37 | ...globals.amd, 38 | GLOBAL: false, 39 | it: false, 40 | expect: false, 41 | describe: false 42 | } 43 | }, 44 | 45 | rules: { 46 | 'prettier/prettier': ['error', prettierOptions], 47 | 48 | 'import/no-webpack-loader-syntax': 0, 49 | curly: ['error', 'all'], 50 | 'react/display-name': 'off', 51 | 'react/react-in-jsx-scope': 'off', 52 | 'no-console': ['error', { allow: ['error'] }], 53 | 'max-lines': ['error', { max: 300, skipBlankLines: true, skipComments: true }], 54 | 'max-lines-per-function': ['error', 250], 55 | 'no-else-return': 'error', 56 | 'max-params': ['error', 4], 57 | 'no-shadow': 'error', 58 | complexity: ['error', 5], 59 | 'no-empty': 'error', 60 | 'import/order': [ 61 | 'error', 62 | { 63 | groups: [['builtin', 'external', 'internal', 'parent', 'sibling', 'index']] 64 | } 65 | ] 66 | }, 67 | 68 | settings: { 69 | 'import/resolver': { 70 | alias: { 71 | map: [ 72 | ['@app', './app'], 73 | ['@components', './app/components'], 74 | ['@themes', './app/themes'], 75 | ['@utils', './app/utils'], 76 | ['@images', './app/images'], 77 | ['@store', './app/store'], 78 | ['@services', './app/services'] 79 | ], 80 | extensions: ['.ts', '.js', '.jsx', '.json'] 81 | } 82 | } 83 | }, 84 | 85 | ignores: [ 86 | 'build', 87 | 'out', 88 | 'node_modules', 89 | 'stats.json', 90 | '.next', 91 | '.DS_Store', 92 | 'npm-debug.log', 93 | '.idea', 94 | '**/coverage/**', 95 | '**/storybook-static/**', 96 | '**/server/**', 97 | 'lighthouserc.js', 98 | 'lingui.config.js', 99 | '__tests__', 100 | 'internals/**/*.*', 101 | 'coverage/**/*.*', 102 | 'reports/**/*.*', 103 | 'badges/**/*.*', 104 | 'assets/**/*.*', 105 | '**/tests/**/*.test.js', 106 | 'playwright.config.js', 107 | 'babel.config.js', 108 | 'app/translations/*.js', 109 | 'app/**/stories/**/*.*' 110 | ] 111 | } 112 | ]; 113 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: [ 3 | 'app/**/*.{js,jsx,ts,tsx}', 4 | 'app/tests/**', 5 | '!app/**/*.d.ts', 6 | '!**/node_modules/**', 7 | '!**/build/**', 8 | '!**/out/**', 9 | '!coverage/**', 10 | '!config/**', 11 | '!pages/**', 12 | '!server/**', 13 | '!app/global-styles.js', 14 | '!*.config.js', 15 | '!**/apiUtils.js', 16 | '!**/testUtils.js', 17 | '!**/themes/index.js', 18 | '!**/utils/constants.js', 19 | '!**/configureStore.js', 20 | '!**/i18n.js', 21 | '!**/reducers.js', 22 | '!**/polyfills.js' 23 | ], 24 | testEnvironment: 'jsdom', 25 | coverageThreshold: { 26 | global: { 27 | statements: 70, 28 | branches: 70, 29 | functions: 70, 30 | lines: 70 31 | } 32 | }, 33 | testPathIgnorePatterns: ['/node_modules/', '/.next/'], 34 | moduleDirectories: ['node_modules', 'app'], 35 | transform: { 36 | '^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/node_modules/babel-jest', 37 | '^.+\\.css$': '<rootDir>/config/jest/cssTransform.js' 38 | }, 39 | transformIgnorePatterns: ['/node_modules/', '^.+\\.module\\.(css|sass|scss)$'], 40 | moduleNameMapper: { 41 | '@app(.*)$': '<rootDir>/app/$1', 42 | '@(components|utils|themes|services|store)(.*)$': '<rootDir>/app/$1/$2', 43 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', 44 | '.*\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ico)$': 45 | '<rootDir>/config/jest/image.js' 46 | }, 47 | setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], 48 | testRegex: 'tests/.*\\.test\\.js$', 49 | snapshotSerializers: ['@emotion/jest/serializer'] 50 | }; 51 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import 'jest-styled-components'; 2 | import '@testing-library/jest-dom'; 3 | import { matchers } from '@emotion/jest'; 4 | 5 | Object.defineProperty(window, 'matchMedia', { 6 | value: jest.fn(() => { 7 | return { 8 | matches: true, 9 | addListener: jest.fn(), 10 | removeListener: jest.fn() 11 | }; 12 | }) 13 | }); 14 | 15 | jest.mock('next/router', () => { 16 | const router = jest.requireActual('next/router'); 17 | return { 18 | __esModule: true, 19 | ...router, 20 | useRouter: () => ({}) 21 | }; 22 | }); 23 | 24 | expect.extend(matchers); 25 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "allowSyntheticDefaultImports": false, 5 | "baseUrl": "./", 6 | // "moduleResolution": "node", 7 | "paths": { 8 | "@app/*": ["./app/*"], 9 | "@components/*": ["./app/components/*"], 10 | "@themes/*": ["./app/themes/*"], 11 | "@utils/*": ["./app/utils/*"], 12 | "@images/*": ["./app/images/*"], 13 | "@store/*": ["./app/store/*"], 14 | "@services/*": ["./app/services/*"] 15 | } 16 | }, 17 | "exclude": ["node_modules", "dist", "build", "out", ".next"] 18 | } 19 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const withImages = require('next-images'); 3 | const path = require('path'); 4 | const withTM = require('next-transpile-modules')([ 5 | '@formatjs/intl-relativetimeformat', 6 | '@formatjs/intl-utils', 7 | 'react-intl', 8 | 'intl-messageformat' 9 | ]); 10 | 11 | const constructAlias = (config) => { 12 | return { 13 | '@app': path.resolve(__dirname, './app'), 14 | '@components': path.resolve(__dirname, './app/components'), 15 | '@themes': path.resolve(__dirname, './app/themes'), 16 | '@utils': path.resolve(__dirname, './app/utils'), 17 | '@images': path.resolve(__dirname, './app/images'), 18 | '@store': path.resolve(__dirname, './app/store'), 19 | '@services': path.resolve(__dirname, './app/services'), 20 | ...config.resolve.alias 21 | }; 22 | }; 23 | 24 | module.exports = withTM( 25 | withImages({ 26 | assetPrefix: process.env.BASE_PATH || undefined, 27 | basePath: process.env.BASE_PATH || '', 28 | trailingSlash: true, 29 | webpack(config) { 30 | config.resolve.alias = constructAlias(config); 31 | const originalEntry = config.entry; 32 | config.entry = async () => { 33 | const entries = await originalEntry(); 34 | 35 | if (entries['main.js'] && !entries['main.js'].includes('./polyfills.js')) { 36 | entries['main.js'].unshift('./polyfills.js'); 37 | } 38 | 39 | return entries; 40 | }; 41 | return config; 42 | } 43 | }) 44 | ); 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-js-template", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build:prod": "env-cmd -f environments/.env.production next build", 7 | "build:qa": "env-cmd -f environments/.env.qa next build", 8 | "build:dev": "env-cmd -f environments/.env.development next build", 9 | "start:prod": "env-cmd -f environments/.env.production next start", 10 | "start:qa": "env-cmd -f environments/.env.qa next dev", 11 | "start:dev": "env-cmd -f environments/.env.development next dev", 12 | "dev": "next dev", 13 | "custom:dev": "env-cmd -f environments/.env.development node server/index.js", 14 | "build": "next build", 15 | "start": "next start", 16 | "test": "jest --coverage", 17 | "test:ci": "jest --ci", 18 | "lint": "npm run lint:js && npm run lint:css", 19 | "lint:css": "stylelint **/*.js", 20 | "lint:eslint": "eslint", 21 | "lint:eslint:fix": "eslint --fix", 22 | "lint:js": "npm run lint:eslint -- . ", 23 | "lint:staged": "lint-staged", 24 | "test:clean": "rimraf ./coverage", 25 | "test:staged": "jest --findRelatedTests", 26 | "test:watch": "NODE_ENV=test jest --watchAll", 27 | "coveralls": "cat ./coverage/lcov.info | coveralls", 28 | "generate": "react-generate" 29 | }, 30 | "lint-staged": { 31 | "*.js": [ 32 | "npm run lint:eslint:fix", 33 | "git add --force", 34 | "jest --findRelatedTests --passWithNoTests $STAGED_FILES" 35 | ], 36 | "*.json": [ 37 | "prettier --write", 38 | "git add --force" 39 | ] 40 | }, 41 | "pre-commit": "lint:staged", 42 | "dependencies": { 43 | "@ant-design/icons": "^5.4.0", 44 | "@emotion/react": "11.13.3", 45 | "@emotion/styled": "^11.13.0", 46 | "@formatjs/intl-relativetimeformat": "^11.2.14", 47 | "@formatjs/intl-utils": "^3.8.4", 48 | "@webcomponents/shadydom": "^1.11.0", 49 | "antd": "^5.20.5", 50 | "apisauce": "^2.1.6", 51 | "env-cmd": "^10.1.0", 52 | "hoist-non-react-statics": "^3.3.2", 53 | "immer": "^9.0.21", 54 | "invariant": "^2.2.4", 55 | "lodash": "^4.17.21", 56 | "next": "14.2.8", 57 | "next-images": "^1.8.5", 58 | "next-redux-wrapper": "^8.1.0", 59 | "prop-types": "^15.8.1", 60 | "react": "18.3.1", 61 | "react-dom": "18.3.1", 62 | "react-intl": "^6.6.8", 63 | "react-redux": "^9.1.2", 64 | "redux": "^5.0.1", 65 | "redux-saga": "^1.3.0", 66 | "reduxsauce": "^1.3.0", 67 | "reselect": "^5.1.1", 68 | "styled-components": "^6.1.13" 69 | }, 70 | "devDependencies": { 71 | "@babel/core": "7.25.2", 72 | "@babel/preset-env": "^7.25.4", 73 | "@babel/preset-react": "^7.24.7", 74 | "@babel/preset-typescript": "^7.24.7", 75 | "@emotion/babel-plugin": "11.12.0", 76 | "@emotion/jest": "^11.13.0", 77 | "@eslint/compat": "^1.1.1", 78 | "@eslint/eslintrc": "^3.1.0", 79 | "@eslint/js": "^9.9.1", 80 | "@stylelint/postcss-css-in-js": "^0.38.0", 81 | "@testing-library/dom": "^10.4.0", 82 | "@testing-library/jest-dom": "^6.5.0", 83 | "@testing-library/react": "^16.0.1", 84 | "@typescript-eslint/eslint-plugin": "^8.4.0", 85 | "@typescript-eslint/parser": "^8.4.0", 86 | "axios-mock-adapter": "^1.22.0", 87 | "babel-jest": "^29.7.0", 88 | "babel-plugin-styled-components": "^2.1.4", 89 | "eslint": "^9.9.1", 90 | "eslint-config-next": "14.2.8", 91 | "eslint-config-prettier": "^9.1.0", 92 | "eslint-config-prettier-standard": "^4.0.1", 93 | "eslint-config-standard": "^17.1.0", 94 | "eslint-import-resolver-alias": "^1.1.2", 95 | "eslint-plugin-immutable": "github:alichherawalla/eslint-plugin-immutable.git#6af48f5498ca1912b618c16bceab8c5464a92f1c", 96 | "eslint-plugin-import": "^2.30.0", 97 | "eslint-plugin-jest": "^28.8.3", 98 | "eslint-plugin-n": "^17.10.2", 99 | "eslint-plugin-prettier": "5.2.1", 100 | "eslint-plugin-promise": "^7.1.0", 101 | "globals": "^15.9.0", 102 | "jest": "^29.7.0", 103 | "jest-cli": "29.7.0", 104 | "jest-environment-jsdom": "^29.7.0", 105 | "jest-styled-components": "^7.2.0", 106 | "lint-staged": "^15.2.10", 107 | "next-transpile-modules": "^10.0.1", 108 | "postcss": "8.4.45", 109 | "postcss-scss": "4.0.9", 110 | "postcss-styled": "0.34.0", 111 | "postcss-styled-syntax": "^0.6.4", 112 | "postcss-syntax": "^0.36.2", 113 | "prettier": "^3.3.3", 114 | "prettier-config-standard": "^7.0.0", 115 | "react-floki": "^1.0.96", 116 | "react-test-renderer": "^18.3.1", 117 | "stylelint": "^16.9.0", 118 | "stylelint-config-recommended": "^14.0.1", 119 | "stylelint-config-standard-scss": "^13.1.0", 120 | "stylelint-config-styled-components": "^0.1.1" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IntlProvider } from 'react-intl'; 3 | import { ThemeProvider } from 'styled-components'; 4 | import colors from '@themes/colors'; 5 | import globalStyle from '@app/global-styles'; 6 | import { Global } from '@emotion/react'; 7 | import { translationMessages, DEFAULT_LOCALE } from '@app/i18n'; 8 | import { wrapper } from '@app/configureStore'; 9 | import PropTypes from 'prop-types'; 10 | const theme = { 11 | colors 12 | }; 13 | 14 | const MyApp = ({ Component, pageProps }) => { 15 | return ( 16 | <IntlProvider locale={DEFAULT_LOCALE} key={DEFAULT_LOCALE} messages={translationMessages[DEFAULT_LOCALE]}> 17 | <ThemeProvider theme={theme}> 18 | <Global styles={globalStyle} /> 19 | <Component {...pageProps} /> 20 | </ThemeProvider> 21 | </IntlProvider> 22 | ); 23 | }; 24 | 25 | MyApp.propTypes = { 26 | Component: PropTypes.elementType.isRequired, 27 | pageProps: PropTypes.object.isRequired 28 | }; 29 | 30 | MyApp.getInitialProps = async ({ Component, ctx }) => { 31 | let pageProps = {}; 32 | if (Component.getInitialProps) { 33 | pageProps = await Component.getInitialProps(ctx); 34 | } 35 | return { pageProps }; 36 | }; 37 | 38 | export default wrapper.withRedux(MyApp); 39 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Head, Html, Main, NextScript } from 'next/document'; 2 | import Script from 'next/script'; 3 | import { ServerStyleSheet } from 'styled-components'; 4 | import PropTypes from 'prop-types'; 5 | const MyDocument = ({ localeDataScript, locale, styles }) => { 6 | // Polyfill Intl API for older browsers 7 | const polyfill = `https://cdn.polyfill.io/v3/polyfill.min.js?features=Intl.~locale.${locale}`; 8 | 9 | return ( 10 | <Html> 11 | <Head /> 12 | <body> 13 | <Main /> 14 | <Script src={polyfill} /> 15 | <Script 16 | id="locale-data" 17 | dangerouslySetInnerHTML={{ 18 | __html: localeDataScript 19 | }} 20 | /> 21 | <NextScript /> 22 | {styles} 23 | </body> 24 | </Html> 25 | ); 26 | }; 27 | 28 | MyDocument.propTypes = { 29 | localeDataScript: PropTypes.string.isRequired, 30 | locale: PropTypes.string.isRequired, 31 | styles: PropTypes.node.isRequired 32 | }; 33 | 34 | MyDocument.getInitialProps = async (ctx) => { 35 | const sheet = new ServerStyleSheet(); 36 | const originalRenderPage = ctx.renderPage; 37 | 38 | try { 39 | ctx.renderPage = () => 40 | originalRenderPage({ 41 | enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />) 42 | }); 43 | 44 | const initialProps = await Document.getInitialProps(ctx); 45 | return { 46 | ...initialProps, 47 | styles: ( 48 | <> 49 | {initialProps.styles} 50 | {sheet.getStyleElement()} 51 | </> 52 | ) 53 | }; 54 | } finally { 55 | sheet.seal(); 56 | } 57 | }; 58 | 59 | export default MyDocument; 60 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Repos from '@app/containers/Repos'; 3 | import { getReccomendations } from '@services/root'; 4 | 5 | /** 6 | * Get the list of recommendations 7 | * @returns {object} The list of recommendations 8 | */ 9 | export async function getStaticProps() { 10 | const recommendations = await getReccomendations(); 11 | return { 12 | props: { 13 | recommendations 14 | } 15 | }; 16 | } 17 | 18 | /** 19 | * The ReposPage component 20 | * @param {object} props The component props 21 | * @param {object} props.recommendations The list of recommendations 22 | */ 23 | export function ReposPage({ recommendations = [] }) { 24 | return <Repos recommendations={recommendations} />; 25 | } 26 | 27 | ReposPage.propTypes = { 28 | recommendations: PropTypes.arrayOf( 29 | PropTypes.shape({ id: PropTypes.number.isRequired, name: PropTypes.string.isRequired }) 30 | ) 31 | }; 32 | 33 | export default ReposPage; 34 | -------------------------------------------------------------------------------- /pages/info/[name].js: -------------------------------------------------------------------------------- 1 | import { getRepo } from '@app/services/info'; 2 | import { getReccomendations } from '@services/root'; 3 | import Info from '@app/containers/Info'; 4 | import PropTypes from 'prop-types'; 5 | 6 | const RepoInfo = ({ details }) => <Info details={details} />; 7 | 8 | RepoInfo.propTypes = { 9 | details: PropTypes.shape({ 10 | name: PropTypes.string.isRequired, 11 | description: PropTypes.string.isRequired, 12 | stargazersCount: PropTypes.number.isRequired 13 | }) 14 | }; 15 | 16 | export default RepoInfo; 17 | 18 | /** 19 | * Get the details of the repo 20 | * @param {object} props The component props 21 | * @param {object} props.params The route parameters 22 | * @returns {object} The details of the repo 23 | * @returns {string} The details.name The name of the repo 24 | * @returns {string} The details.description The description of the repo 25 | * @returns {number} The details.stargazersCount The number of stargazers 26 | */ 27 | export async function getStaticProps(props) { 28 | const { 29 | params: { name } 30 | } = props; 31 | const details = await getRepo(name); 32 | return { 33 | props: { 34 | details 35 | } 36 | }; 37 | } 38 | 39 | /** 40 | * Get the list of recommendations 41 | * @returns {object} The list of recommendations 42 | */ 43 | export async function getStaticPaths() { 44 | const recommendations = await getReccomendations(); 45 | // * param value should be a string 46 | const paths = recommendations.map(({ name, owner }) => ({ params: { name, owner } })); 47 | return { 48 | paths, 49 | fallback: true 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /polyfills.js: -------------------------------------------------------------------------------- 1 | import '@webcomponents/shadydom'; 2 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const { createServer } = require('http') 2 | const { URL } = require('url') 3 | const next = require('next') 4 | 5 | const dev = process.env.NODE_ENV !== 'production' 6 | const app = next({ dev }) 7 | const handle = app.getRequestHandler() 8 | 9 | console.log(process.env.GITHUB_URL, 'GITHUB_NEXT') 10 | 11 | app.prepare().then(() => { 12 | createServer((req, res) => { 13 | // 'url.parse' was deprecated since v11.0.0. 14 | const parsedUrl = new URL(req.url, process.env.GITHUB_URL) 15 | const { pathname, query } = parsedUrl 16 | 17 | if (pathname === '/a') { 18 | app.render(req, res, '/a', query) 19 | } else if (pathname === '/b') { 20 | app.render(req, res, '/b', query) 21 | } else { 22 | handle(req, res, parsedUrl) 23 | } 24 | }).listen(3000, (err) => { 25 | if (err) throw err 26 | console.log('> Ready on http://localhost:3000') 27 | }) 28 | }) 29 | --------------------------------------------------------------------------------