├── .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 |
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 |
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 |
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 |
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 |
11 |
12 |
16 |
20 | default
21 |
22 |
26 |
31 |
44 |
45 | (
46 | )
47 |
48 |
49 |
50 |
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('', () => {
13 | it('should render and match the snapshot', () => {
14 | const { baseElement } = renderProvider()
15 | expect(baseElement).toMatchSnapshot()
16 | })
17 |
18 | it('should contain 1 Title component', () => {
19 | const { getAllByTestId } = renderProvider()
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[` 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('', () => {
6 | it('should ensure it match the snapshot', () => {
7 | const { Element } = render()
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 |
52 |
53 |
54 |
61 |
62 | {description}
63 |
64 |
65 |
66 |
67 |
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[` 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 |
19 |
103 |
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(' 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()
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 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | handleOnChange(evt.target.value)}
91 | onSearch={(searchText) => handleOnChange(searchText)}
92 | />
93 |
94 |
95 |
96 |
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[` 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 |
54 |
55 |
59 |
62 |
66 | Recommended
67 |
68 |
69 |
88 |
92 |
96 |
99 |
102 |
105 | Repository Search
106 |
107 |
108 |
109 |
112 |
116 | Get details of repositories
117 |
118 |
121 |
124 |
130 |
133 |
161 |
162 |
163 |
164 |
165 |
166 |
171 |
174 |
177 |
180 | Repository List
181 |
182 |
183 |
184 |
187 |
191 | Search for a repository by entering it's name in the search box
192 |
193 |
194 |
195 |
196 |
197 |
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(' 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()
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 ;
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 |
21 | {children}
22 |
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 |
35 |
36 | {children}
37 |
38 |
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 |
51 |
52 |
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 |
67 |
68 |
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 |
114 |
115 |
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 |
129 |
130 |
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)$': '/node_modules/babel-jest',
37 | '^.+\\.css$': '/config/jest/cssTransform.js'
38 | },
39 | transformIgnorePatterns: ['/node_modules/', '^.+\\.module\\.(css|sass|scss)$'],
40 | moduleNameMapper: {
41 | '@app(.*)$': '/app/$1',
42 | '@(components|utils|themes|services|store)(.*)$': '/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 | '/config/jest/image.js'
46 | },
47 | setupFilesAfterEnv: ['/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 |
17 |
18 |
19 |
20 |
21 |
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 |
11 |
12 |
13 |
14 |
15 |
21 |
22 | {styles}
23 |
24 |
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()
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 ;
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 }) => ;
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 |
--------------------------------------------------------------------------------