├── .editorconfig
├── .env
├── .env.development
├── .env.local
├── .eslintignore
├── .eslintrc.js
├── .gitattributes
├── .github
├── pull_request_template.md
└── workflows
│ ├── beta-release.yml
│ ├── cd.yml
│ ├── ci.yml
│ ├── jest-coverage.yml
│ └── prod-release.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── .storybook
├── addons.js
├── config.js
└── webpack.config.js
├── .stylelintrc
├── LICENSE
├── README.md
├── app
├── .htaccess
├── .nginx.conf
├── app.tsx
├── components
│ ├── Clickable
│ │ ├── index.tsx
│ │ ├── stories
│ │ │ └── Clickable.stories.js
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ ├── ErrorBoundary
│ │ └── index.tsx
│ ├── ErrorHandler
│ │ ├── index.tsx
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.js.snap
│ │ │ └── index.test.js
│ ├── For
│ │ ├── index.tsx
│ │ └── tests
│ │ │ └── index.test.tsx
│ ├── Header
│ │ ├── index.tsx
│ │ ├── stories
│ │ │ └── Header.stories.js
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ ├── If
│ │ └── index.tsx
│ ├── LaunchDetails
│ │ ├── index.tsx
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ ├── LaunchItem
│ │ ├── index.tsx
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ ├── LaunchList
│ │ ├── index.tsx
│ │ ├── stories
│ │ │ └── LaunchList.stories.js
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ ├── ProtectedRoute
│ │ ├── index.tsx
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ ├── ScrollToTop
│ │ └── index.tsx
│ ├── Siderbar
│ │ ├── index.tsx
│ │ └── tests
│ │ │ └── index.test.tsx
│ ├── StyledContainer
│ │ ├── index.tsx
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ ├── T
│ │ ├── index.tsx
│ │ ├── stories
│ │ │ └── T.stories.js
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ └── index.ts
├── configureStore.ts
├── containers
│ ├── App
│ │ ├── index.tsx
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ ├── HomeContainer
│ │ ├── Loadable.ts
│ │ ├── index.tsx
│ │ ├── queries.ts
│ │ ├── reducer.ts
│ │ ├── saga.ts
│ │ ├── selectors.ts
│ │ ├── tests
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ ├── mockData.js
│ │ │ ├── reducer.test.ts
│ │ │ ├── saga.test.ts
│ │ │ └── selectors.test.ts
│ │ ├── types.ts
│ │ ├── usePaginate.ts
│ │ └── useSort.ts
│ ├── LanguageProvider
│ │ ├── index.tsx
│ │ ├── reducer.ts
│ │ ├── selectors.ts
│ │ └── tests
│ │ │ ├── index.test.tsx
│ │ │ ├── reducer.test.ts
│ │ │ └── selectors.test.ts
│ ├── LaunchDetails
│ │ ├── Loadable.ts
│ │ ├── index.tsx
│ │ ├── queries.ts
│ │ ├── reducer.ts
│ │ ├── saga.ts
│ │ ├── selectors.ts
│ │ ├── tests
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ ├── reducer.test.ts
│ │ │ ├── saga.test.ts
│ │ │ └── selectors.test.ts
│ │ └── types.ts
│ └── NotFoundPage
│ │ ├── Loadable.tsx
│ │ ├── index.tsx
│ │ └── tests
│ │ ├── __snapshots__
│ │ └── index.test.tsx.snap
│ │ └── index.test.tsx
├── global-styles.ts
├── i18n.ts
├── images
│ ├── ArrowDown.svg
│ ├── ArrowUp.svg
│ ├── ArrowUpDown.svg
│ ├── favicon.ico
│ ├── icon-512x512.png
│ ├── ion_rocket-sharp.svg
│ ├── kai-pilger-Ef6iL87-vOA-unsplash.jpg
│ ├── menu.svg
│ ├── undraw_page_not_found_re_e9o6.svg
│ └── undraw_to_the_stars_re_wq2x.svg
├── index.html
├── reducers.ts
├── routeConfig.ts
├── tests
│ └── i18n.test.ts
├── themes
│ ├── colors.js
│ ├── fonts.ts
│ ├── index.ts
│ ├── media.ts
│ ├── styles.ts
│ └── tests
│ │ ├── colors.test.js
│ │ ├── fonts.test.ts
│ │ └── styles.test.ts
├── translations
│ ├── en.js
│ └── en.json
├── tsconfig.json
├── utils
│ ├── apiUtils.ts
│ ├── constants.ts
│ ├── graphqlUtils.ts
│ ├── history.ts
│ ├── index.ts
│ ├── loadable.tsx
│ ├── routeConstants.ts
│ ├── testUtils.tsx
│ ├── tests
│ │ ├── graphqlUtils.test.ts
│ │ ├── history.test.ts
│ │ └── index.test.ts
│ └── useMedia.ts
└── vendor.d.ts
├── babel.config.js
├── badges
├── badge-branches.svg
├── badge-functions.svg
├── badge-lines.svg
└── badge-statements.svg
├── internals
├── mocks
│ ├── cssModule.js
│ └── image.js
├── scripts
│ ├── analyze.js
│ ├── clean.js
│ ├── extract-intl.js
│ ├── generate-templates-for-linting.js
│ ├── helpers
│ │ ├── checkmark.js
│ │ ├── get-npm-config.js
│ │ ├── progress.js
│ │ └── xmark.js
│ ├── npmcheckversion.js
│ ├── setup.js
│ └── tsc-lint.sh
├── testing
│ └── test-bundler.js
└── webpack
│ ├── webpack.config.base.js
│ ├── webpack.config.dev.js
│ └── webpack.config.prod.js
├── jest.config.json
├── jest.setup.js
├── lingui.config.js
├── package.json
├── react-graphql-template.png
├── server
├── argv.js
├── index.js
├── logger.js
├── middlewares
│ ├── addDevMiddlewares.js
│ ├── addProdMiddlewares.js
│ └── frontendMiddleware.js
└── port.js
├── sonar-project.properties
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | insert_final_newline = true
9 | indent_style = space
10 | indent_size = 2
11 | trim_trailing_whitespace = true
12 |
13 | [*.md]
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | SPACEX_URL=https://spacex-production.up.railway.app/
2 |
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | SPACEX_URL=https://spacex-production.up.railway.app/
2 |
--------------------------------------------------------------------------------
/.env.local:
--------------------------------------------------------------------------------
1 | SPACEX_URL=https://spacex-production.up.railway.app/
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | stats.json
4 |
5 | .DS_Store
6 | npm-debug.log
7 | .idea
8 | **/coverage/**
9 | **/storybook-static/**
10 | **/server/**
11 | __tests__
12 | internals/**/*.*
13 | coverage/**/*.*
14 | reports/**/*.*
15 | badges/**/*.*
16 | assets/**/*.*
17 | **/tests/**/*.test.js
18 | playwright.config.js
19 | babel.config.js
20 | app/translations/*.js
21 | app/**/stories/**/*.*
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | const prettierOptions = JSON.parse(fs.readFileSync(path.resolve(__dirname, '.prettierrc'), 'utf8'));
5 |
6 | module.exports = {
7 | parser: '@typescript-eslint/parser',
8 | env: {
9 | browser: true,
10 | es6: true,
11 | amd: true,
12 | 'jest/globals': true,
13 | commonjs: true
14 | },
15 | plugins: ['react', 'redux-saga', 'react-hooks', 'jest', '@typescript-eslint'],
16 | extends: [
17 | 'prettier',
18 | 'prettier/react',
19 | 'prettier-standard',
20 | 'plugin:react/recommended',
21 | 'plugin:react/jsx-runtime',
22 | 'eslint:recommended',
23 | 'plugin:@typescript-eslint/recommended'
24 | ],
25 | rules: {
26 | 'import/no-webpack-loader-syntax': 0,
27 | 'react/display-name': 0,
28 | curly: ['error', 'all'],
29 | 'no-console': ['error', { allow: ['error'] }],
30 | 'max-lines': ['error', { max: 350, skipBlankLines: true, skipComments: true }],
31 | 'max-lines-per-function': ['error', 250],
32 | 'no-else-return': 'error',
33 | 'max-params': ['error', 3],
34 | 'prettier/prettier': ['error', prettierOptions],
35 | '@typescript-eslint/no-unused-vars': 'error',
36 | '@typescript-eslint/no-empty-function': 'off',
37 | '@typescript-eslint/no-var-requires': 'off',
38 | '@typescript-eslint/ban-types': 'off',
39 | '@typescript-eslint/no-explicit-any': 'off',
40 | '@typescript-eslint/no-non-null-assertion': 'off',
41 | 'eslint-comments/no-use': 0
42 | },
43 | globals: {
44 | GLOBAL: false,
45 | it: false,
46 | expect: false,
47 | describe: false
48 | }
49 | };
50 |
--------------------------------------------------------------------------------
/.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/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ### Ticket Link
2 |
3 | ---
4 |
5 | ### Related Links
6 |
7 | ---
8 |
9 | ### Description
10 |
11 | ---
12 |
13 | ### Steps to Reproduce / Test
14 |
15 | ---
16 |
17 | ---
18 |
19 | ### Checklist
20 |
21 | - [ ] PR description included
22 | - [ ] `yarn test` passes
23 | - [ ] Tests are [changed or added]
24 | - [ ] Relevant documentation is changed or added (and PR referenced)
25 |
26 | ### GIF's
27 |
28 | ---
29 |
--------------------------------------------------------------------------------
/.github/workflows/beta-release.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - qa
5 |
6 | name: Create Beta Release
7 |
8 | jobs:
9 | build:
10 | name: Create Beta Release
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Actions Ecosystem Action Get Merged Pull Request
14 | uses: actions-ecosystem/action-get-merged-pull-request@v1.0.1
15 | id: getMergedPR
16 | with:
17 | github_token: ${{ secrets.GITHUB_TOKEN }}
18 | - name: Checkout code
19 | uses: actions/checkout@v2
20 | - run: |
21 | git fetch --prune --unshallow --tags
22 | - name: Get Commit Message
23 | run: |
24 | declare -A category=( [fix]="" [chore]="" [revert]="" [build]="" [docs]="" [feat]="" [perf]="" [refactor]="" [style]="" [temp]="" [test]="" [ci]="" [others]="")
25 | declare -A categoryTitle=( [fix]="
Bug Fixes
" [build]="Build
" [docs]="Documentation
" [feat]="New Features
" [chore]="Changes to build process or aux tools
" [ci]="Changes to CI config
" [temp]="Temporary commit
" [perf]="Performance Enhancement
" [revert]="Revert Commits
" [refactor]="Refactored
" [style]="Changed Style
" [test]="Added Tests
" [others]="Others
")
26 | msg="#${{ steps.getMergedPR.outputs.number}} ${{ steps.getMergedPR.outputs.title}}"
27 | for i in $(git log --format=%h $(git merge-base HEAD^1 HEAD^2)..HEAD^2)
28 | do
29 | IFS=":" read -r type cmmsg <<< $(git log --format=%B -n 1 $i)
30 | type="${type}" | xargs
31 | text_msg=" • $i - ${cmmsg}
"
32 | flag=1
33 | for i in "${!category[@]}"
34 | do
35 | if [ "${type}" == "$i" ]
36 | then
37 | category[$i]+="${text_msg}"
38 | flag=0
39 | break
40 | fi
41 | done
42 | if [ $flag -eq 1 ]
43 | then
44 | category[others]+="${text_msg}"
45 | fi
46 | done
47 | for i in "${!category[@]}"
48 | do
49 | if [ ! -z "${category[$i]}" ] && [ "others" != "$i" ]
50 | then
51 | msg+="${categoryTitle[$i]}${category[$i]}"
52 | fi
53 | done
54 | # if [ ! -z "${category[others]}" ]
55 | # then
56 | # msg+="${categoryTitle[others]}${category[others]}"
57 | # fi
58 | echo "COMMIT_MESSAGE=${msg}" >> $GITHUB_ENV
59 | - name: Bump version and push tag
60 | run: |
61 | cd "$GITHUB_WORKSPACE"
62 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
63 | git config user.name "$GITHUB_ACTOR"
64 | npm version patch
65 | git push && git push --tags
66 | - name: get-npm-version
67 | id: package-version
68 | uses: martinbeentjes/npm-get-version-action@master
69 | - name: Create Beta Release
70 | uses: actions/create-release@latest
71 | env:
72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
73 | with:
74 | tag_name: ${{ steps.package-version.outputs.current-version}}-Beta
75 | release_name: v${{ steps.package-version.outputs.current-version}}-Beta
76 | body: ${{ env.COMMIT_MESSAGE }}
77 | draft: false
78 | prerelease: false
79 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yml:
--------------------------------------------------------------------------------
1 | name: React GraphQL TypeScript Template CD
2 | on:
3 | push:
4 | branches: [master]
5 | jobs:
6 | build-and-deploy:
7 | runs-on: ubuntu-latest
8 | strategy:
9 | matrix:
10 | node-version: [16.x]
11 |
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v2
15 |
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v2
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | cache: 'yarn'
21 |
22 | - name: Install dependencies
23 | run: yarn
24 |
25 | - name: Build
26 | run: yarn build:prod
27 |
28 | - name: Deploy to S3
29 | uses: jakejarvis/s3-sync-action@master
30 | with:
31 | args: --acl public-read --follow-symlinks --delete
32 | env:
33 | AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
34 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
35 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
36 | AWS_REGION: ${{ secrets.AWS_REGION }}
37 | SOURCE_DIR: './build/'
38 |
39 | - name: Set branch name
40 | id: vars
41 | run: echo ::set-output name=stage::${GITHUB_REF#refs/*/}
42 |
43 | - name: Create badges
44 | run: yarn run test:badges
45 |
46 | - name: Commit badges
47 | uses: EndBug/add-and-commit@v7
48 | with:
49 | author_name: Gitflow
50 | author_email: git@wednesday.is
51 | message: 'Update badges'
52 | add: 'badges/'
53 | push: false
54 | - name: Git pull origin
55 | run: |
56 | git pull origin ${{ github.ref }}
57 | - name: Pushing to a protected branch
58 | uses: CasperWA/push-protected@v2
59 | with:
60 | token: ${{ secrets.PUSH_TO_PROTECTED_BRANCH }}
61 | branch: ${{ steps.vars.outputs.stage }}
62 | unprotect_reviews: true
63 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: React Template CI
2 | on:
3 | pull_request_target:
4 | branches: [master, qa, develop]
5 | jobs:
6 | build-and-test:
7 | name: Build & Test
8 | runs-on: ubuntu-latest
9 | strategy:
10 | matrix:
11 | node-version: [16.x]
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v2
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | cache: 'yarn'
20 |
21 | - name: Install dependencies
22 | run: yarn
23 | - name: Lint
24 | run: yarn lint
25 |
26 | - name: Test
27 | run: yarn test
28 |
29 | - name: Build
30 | run: yarn build:prod
31 |
32 | # - name: SonarCloud Scan
33 | # uses: sonarsource/sonarcloud-github-action@master
34 | # with:
35 | # args: >
36 | # -Dsonar.organization=${{ secrets.SONAR_ORG}}
37 | # -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY}}
38 | # env:
39 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40 | # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
41 |
--------------------------------------------------------------------------------
/.github/workflows/jest-coverage.yml:
--------------------------------------------------------------------------------
1 | name: Jest Coverage Report with Annotations (CI)
2 | on:
3 | pull_request_target:
4 | branches:
5 | - master
6 | - qa
7 | - develop
8 | jobs:
9 | coverage_report:
10 | name: Jest Coverage Report
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | node-version: [16.x]
15 | steps:
16 | - uses: actions/checkout@v2
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v2
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | cache: 'yarn'
22 |
23 | - name: Get Threshold
24 | id: threshold
25 | uses: notiz-dev/github-action-json-property@release
26 | with:
27 | path: 'jest.config.json'
28 | prop_path: 'coverageThreshold.global.statements'
29 |
30 | - name: Install dependencies
31 | run: yarn
32 |
33 | - name: Test and generate coverage report
34 | uses: artiomtr/jest-coverage-report-action@v2.0-rc.4
35 | with:
36 | github-token: ${{ secrets.GITHUB_TOKEN }}
37 | threshold: ${{steps.threshold.outputs.prop}}
38 | package-manager: yarn
39 | custom-title: Jest Coverage Report
40 |
--------------------------------------------------------------------------------
/.github/workflows/prod-release.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - master
5 |
6 | name: Create Production Release
7 |
8 | jobs:
9 | build:
10 | name: Create Production Release
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Actions Ecosystem Action Get Merged Pull Request
14 | uses: actions-ecosystem/action-get-merged-pull-request@v1.0.1
15 | id: getMergedPR
16 | with:
17 | github_token: ${{ secrets.GITHUB_TOKEN }}
18 | - name: Checkout code
19 | uses: actions/checkout@v2
20 | - run: |
21 | git fetch --prune --unshallow --tags
22 | - name: Get Commit Message
23 | run: |
24 | declare -A category=( [fix]="" [chore]="" [revert]="" [build]="" [docs]="" [feat]="" [perf]="" [refactor]="" [style]="" [temp]="" [test]="" [ci]="" [others]="")
25 | declare -A categoryTitle=( [fix]="Bug Fixes
" [build]="Build
" [docs]="Documentation
" [feat]="New Features
" [chore]="Changes to build process or aux tools
" [ci]="Changes to CI config
" [temp]="Temporary commit
" [perf]="Performance Enhancement
" [revert]="Revert Commits
" [refactor]="Refactored
" [style]="Changed Style
" [test]="Added Tests
" [others]="Others
")
26 | msg="#${{ steps.getMergedPR.outputs.number}} ${{ steps.getMergedPR.outputs.title}}"
27 | for i in $(git log --format=%h $(git merge-base HEAD^1 HEAD^2)..HEAD^2)
28 | do
29 | IFS=":" read -r type cmmsg <<< $(git log --format=%B -n 1 $i)
30 | type="${type}" | xargs
31 | text_msg=" • $i - ${cmmsg}
"
32 | flag=1
33 | for i in "${!category[@]}"
34 | do
35 | if [ "${type}" == "$i" ]
36 | then
37 | category[$i]+="${text_msg}"
38 | flag=0
39 | break
40 | fi
41 | done
42 | if [ $flag -eq 1 ]
43 | then
44 | category[others]+="${text_msg}"
45 | fi
46 | done
47 | for i in "${!category[@]}"
48 | do
49 | if [ ! -z "${category[$i]}" ] && [ "others" != "$i" ]
50 | then
51 | msg+="${categoryTitle[$i]}${category[$i]}"
52 | fi
53 | done
54 | if [ ! -z "${category[others]}" ]
55 | then
56 | msg+="${categoryTitle[others]}${category[others]}"
57 | fi
58 | echo "COMMIT_MESSAGE=${msg}" >> $GITHUB_ENV
59 | - name: Bump version and push tag
60 | run: |
61 | cd "$GITHUB_WORKSPACE"
62 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
63 | git config user.name "$GITHUB_ACTOR"
64 | npm version patch
65 | git push
66 | - name: get-npm-version
67 | id: package-version
68 | uses: martinbeentjes/npm-get-version-action@master
69 | - name: Create Prod Release
70 | uses: actions/create-release@latest
71 | env:
72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
73 | with:
74 | tag_name: ${{ steps.package-version.outputs.current-version}}
75 | release_name: v${{ steps.package-version.outputs.current-version}}
76 | body: ${{ env.COMMIT_MESSAGE }}
77 | draft: false
78 | prerelease: false
79 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Don't check auto-generated stuff into git
2 | .history/
3 | build
4 | node_modules
5 | stats.json
6 | coverage/
7 | .vscode/
8 | switcher_backup.env
9 | yarn-error.log
10 | storybook-static
11 | reports/
12 | # Cruft
13 | .DS_Store
14 | npm-debug.log
15 | .idea
16 | .tsconfig-lint.json
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/dubnium
2 |
--------------------------------------------------------------------------------
/.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 | coverage/
10 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "singleQuote": true,
6 | "trailingComma": "none",
7 | "indent": 2
8 | }
9 |
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-knobs/register';
2 | import '@storybook/addon-actions/register';
3 | import 'storybook-addon-intl/register';
4 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { configure } from '@storybook/react';
3 | import { addDecorator } from '@storybook/react';
4 | import { withKnobs } from '@storybook/addon-knobs';
5 | import StoryRouter from 'storybook-router';
6 | import { withSmartKnobs } from 'storybook-addon-smart-knobs';
7 | import { setIntlConfig, withIntl } from 'storybook-addon-intl';
8 | import { translationMessages, appLocales, DEFAULT_LOCALE } from '../app/i18n.ts';
9 |
10 | Object.values = (obj) => Object.keys(obj).map((key) => obj[key]);
11 |
12 | addDecorator(withKnobs);
13 | addDecorator(withSmartKnobs);
14 | addDecorator(StoryRouter());
15 |
16 | const getMessages = (locale) => translationMessages[locale];
17 | setIntlConfig({
18 | locales: appLocales,
19 | defaultLocale: DEFAULT_LOCALE,
20 | getMessages
21 | });
22 |
23 | addDecorator(withIntl);
24 |
25 | // automatically import all files ending in *.stories.js
26 | function requireAll(requireContext) {
27 | return requireContext.keys().map((key) => {
28 | return requireContext;
29 | });
30 | }
31 |
32 | function loadStories() {
33 | const req = require.context('../app/components/', true, /^.*\.stories$/);
34 | return requireAll(req);
35 | }
36 | configure(loadStories(), module);
37 |
--------------------------------------------------------------------------------
/.storybook/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const genBaseConfig = require('../internals/webpack/webpack.config.base');
3 | const colors = require('../app/themes/colors');
4 |
5 | module.exports = ({ config }) => {
6 | // hack cause smart knobs is not working on production
7 | process.env.NODE_ENV = 'development';
8 |
9 | config.resolve.alias = genBaseConfig(config).resolve.alias;
10 |
11 | config.module.rules.push({
12 | test: /\.less$/,
13 | use: [
14 | {
15 | loader: 'style-loader'
16 | },
17 | {
18 | loader: 'css-loader'
19 | },
20 | {
21 | loader: 'less-loader',
22 | options: {
23 | lessOptions: {
24 | javascriptEnabled: true,
25 | modifyVars: {
26 | 'primary-color': colors.secondary
27 | }
28 | }
29 | }
30 | }
31 | ],
32 | include: path.resolve(__dirname, '../')
33 | });
34 | config.resolve.modules.push('app');
35 | config.resolve.extensions.push('.js', '.jsx', '.react.js');
36 | config.module.rules[0].use[0].options.plugins = [require.resolve('babel-plugin-react-docgen')];
37 | return config;
38 | };
39 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "processors": ["stylelint-processor-styled-components"],
3 | "extends": [
4 | "stylelint-config-recommended",
5 | "stylelint-config-styled-components"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020-Present Mohammed Ali Chherawalla
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/.htaccess:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | #######################################################################
5 | # GENERAL #
6 | #######################################################################
7 |
8 | # Make apache follow sym links to files
9 | Options +FollowSymLinks
10 | # If somebody opens a folder, hide all files from the resulting folder list
11 | IndexIgnore */*
12 |
13 |
14 | #######################################################################
15 | # REWRITING #
16 | #######################################################################
17 |
18 | # Enable rewriting
19 | RewriteEngine On
20 |
21 | # If its not HTTPS
22 | RewriteCond %{HTTPS} off
23 |
24 | # Comment out the RewriteCond above, and uncomment the RewriteCond below if you're using a load balancer (e.g. CloudFlare) for SSL
25 | # RewriteCond %{HTTP:X-Forwarded-Proto} !https
26 |
27 | # Redirect to the same URL with https://, ignoring all further rules if this one is in effect
28 | RewriteRule ^(.*) https://%{HTTP_HOST}/$1 [R,L]
29 |
30 | # If we get to here, it means we are on https://
31 |
32 | # If the file with the specified name in the browser doesn't exist
33 | RewriteCond %{REQUEST_FILENAME} !-f
34 |
35 | # and the directory with the specified name in the browser doesn't exist
36 | RewriteCond %{REQUEST_FILENAME} !-d
37 |
38 | # and we are not opening the root already (otherwise we get a redirect loop)
39 | RewriteCond %{REQUEST_FILENAME} !\/$
40 |
41 | # Rewrite all requests to the root
42 | RewriteRule ^(.*) /
43 |
44 |
45 |
46 |
47 | # Do not cache sw.js, required for offline-first updates.
48 |
49 | Header set Cache-Control "private, no-cache, no-store, proxy-revalidate, no-transform"
50 | Header set Pragma "no-cache"
51 |
52 |
53 |
--------------------------------------------------------------------------------
/app/.nginx.conf:
--------------------------------------------------------------------------------
1 | ##
2 | # Put this file in /etc/nginx/conf.d folder and make sure
3 | # you have a line 'include /etc/nginx/conf.d/*.conf;'
4 | # in your main nginx configuration file
5 | ##
6 |
7 | ##
8 | # Redirect to the same URL with https://
9 | ##
10 |
11 | server {
12 |
13 | listen 80;
14 |
15 | # Type your domain name below
16 | server_name example.com;
17 |
18 | return 301 https://$server_name$request_uri;
19 |
20 | }
21 |
22 | ##
23 | # HTTPS configurations
24 | ##
25 |
26 | server {
27 |
28 | listen 443 ssl;
29 |
30 | # Type your domain name below
31 | server_name example.com;
32 |
33 | # Configure the Certificate and Key you got from your CA (e.g. Lets Encrypt)
34 | ssl_certificate /path/to/certificate.crt;
35 | ssl_certificate_key /path/to/server.key;
36 |
37 | ssl_session_timeout 1d;
38 | ssl_session_cache shared:SSL:50m;
39 | ssl_session_tickets off;
40 |
41 | # Only use TLS v1.2 as Transport Security Protocol
42 | ssl_protocols TLSv1.2;
43 |
44 | # Only use ciphersuites that are considered modern and secure by Mozilla
45 | ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';
46 |
47 | # Do not let attackers downgrade the ciphersuites in Client Hello
48 | # Always use server-side offered ciphersuites
49 | ssl_prefer_server_ciphers on;
50 |
51 | # HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months)
52 | add_header Strict-Transport-Security max-age=15768000;
53 |
54 | # Diffie-Hellman parameter for DHE ciphersuites, recommended 2048 bits
55 | # Uncomment if you want to use your own Diffie-Hellman parameter, which can be generated with: openssl ecparam -genkey -out dhparam.pem -name prime256v1
56 | # See https://wiki.mozilla.org/Security/Server_Side_TLS#DHE_handshake_and_dhparam
57 | # ssl_dhparam /path/to/dhparam.pem;
58 |
59 |
60 | ## OCSP Configuration START
61 | # If you want to provide OCSP Stapling, you can uncomment the following lines
62 | # See https://www.digitalocean.com/community/tutorials/how-to-configure-ocsp-stapling-on-apache-and-nginx for more infos about OCSP and its use case
63 | # fetch OCSP records from URL in ssl_certificate and cache them
64 |
65 | #ssl_stapling on;
66 | #ssl_stapling_verify on;
67 |
68 | # verify chain of trust of OCSP response using Root CA and Intermediate certs (you will get this file from your CA)
69 | #ssl_trusted_certificate /path/to/root_CA_cert_plus_intermediates;
70 |
71 | ## OCSP Configuration END
72 |
73 | # To let nginx use its own DNS Resolver
74 | # resolver ;
75 |
76 |
77 | # Always serve index.html for any request
78 | location / {
79 | # Set path
80 | root /var/www/;
81 | try_files $uri /index.html;
82 | }
83 |
84 | # Do not cache sw.js, required for offline-first updates.
85 | location /sw.js {
86 | add_header Cache-Control "no-cache";
87 | proxy_cache_bypass $http_pragma;
88 | proxy_cache_revalidate on;
89 | expires off;
90 | access_log off;
91 | }
92 |
93 | ##
94 | # If you want to use Node/Rails/etc. API server
95 | # on the same port (443) config Nginx as a reverse proxy.
96 | # For security reasons use a firewall like ufw in Ubuntu
97 | # and deny port 3000/tcp.
98 | ##
99 |
100 | # location /api/ {
101 | #
102 | # proxy_pass http://localhost:3000;
103 | # proxy_http_version 1.1;
104 | # proxy_set_header X-Forwarded-Proto https;
105 | # proxy_set_header Upgrade $http_upgrade;
106 | # proxy_set_header Connection 'upgrade';
107 | # proxy_set_header Host $host;
108 | # proxy_cache_bypass $http_upgrade;
109 | #
110 | # }
111 |
112 | }
113 |
--------------------------------------------------------------------------------
/app/app.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * app.js
3 | *
4 | * This is the entry file for the application, only setup and boilerplate
5 | * code.
6 | */
7 |
8 | // Needed for redux-saga es6 generator support
9 | // Import all the third party stuff
10 | import React from 'react';
11 | import { createRoot, Root } from 'react-dom/client';
12 | import { Provider } from 'react-redux';
13 | import { PersistGate } from 'redux-persist/integration/react';
14 | import history from '@utils/history';
15 | import 'sanitize.css/sanitize.css';
16 |
17 | // Import root app
18 | import App from '@containers/App';
19 |
20 | // Import Language Provider
21 | import LanguageProvider from '@containers/LanguageProvider';
22 | import ErrorBoundary from '@components/ErrorBoundary';
23 | import ScrollToTop from '@components/ScrollToTop';
24 | // Load the favicon and the .htaccess file
25 | /* eslint-disable import/no-unresolved, import/extensions */
26 | import '!file-loader?name=[name].[ext]!./images/favicon.ico';
27 | import 'file-loader?name=.htaccess!./.htaccess';
28 | /* eslint-enable import/no-unresolved, import/extensions */
29 |
30 | import configureStore from './configureStore';
31 |
32 | // Import i18n messages
33 | import { translationMessages } from './i18n';
34 | import { Router } from 'react-router-dom';
35 |
36 | // Create redux store with history
37 | const initialState = {};
38 | const { store, persistor } = configureStore(initialState);
39 | const MOUNT_NODE = document.getElementById('app');
40 | let root: Root;
41 | const render = (messages: typeof translationMessages) => {
42 | root = createRoot(MOUNT_NODE as HTMLElement);
43 | root.render(
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | };
59 |
60 | if (module.hot) {
61 | // Hot reloadable React components and translation json files
62 | // modules.hot.accept does not accept dynamic dependencies,
63 | // have to be constants at compile-time
64 | module.hot.accept(['./i18n', 'containers/App'], () => {
65 | root.unmount();
66 | render(translationMessages);
67 | });
68 | }
69 |
70 | // Chunked polyfill for browsers without Intl support
71 | if (!window.Intl) {
72 | new Promise((resolve) => {
73 | resolve(import('intl'));
74 | })
75 | .then(() => Promise.all([import('intl/locale-data/jsonp/en.js')]))
76 | .then(() => render(translationMessages))
77 | .catch((err) => {
78 | throw err;
79 | });
80 | } else {
81 | render(translationMessages);
82 | }
83 |
84 | // Install ServiceWorker and AppCache in the end since
85 | // it's not most important operation and if main code fails,
86 | // we do not want it installed
87 | if (process.env.NODE_ENV === 'production') {
88 | require('@lcdp/offline-plugin/runtime').install({
89 | onUpdated: () => {
90 | // Reload the webpage to load into the new version
91 | window.location.reload();
92 | }
93 | }); // eslint-disable-line global-require
94 | }
95 |
--------------------------------------------------------------------------------
/app/components/Clickable/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Clickable
4 | *
5 | */
6 | import React from 'react';
7 | import PropTypes from 'prop-types';
8 | import styled from 'styled-components';
9 | import T from '@components/T';
10 |
11 | const StyledClickable = styled.div`
12 | color: #1890ff;
13 | &:hover {
14 | cursor: pointer;
15 | }
16 | `;
17 | interface ClickableProps {
18 | onClick: React.MouseEventHandler | undefined;
19 | textId: string;
20 | }
21 | export function Clickable({ onClick, textId }: ClickableProps) {
22 | return (
23 |
24 | {textId && }
25 |
26 | );
27 | }
28 |
29 | Clickable.propTypes = {
30 | onClick: PropTypes.func.isRequired,
31 | textId: PropTypes.string.isRequired
32 | };
33 |
34 | export default Clickable;
35 |
--------------------------------------------------------------------------------
/app/components/Clickable/stories/Clickable.stories.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Stories for Clickable
4 | *
5 | * @see https://github.com/storybookjs/storybook
6 | *
7 | */
8 |
9 | import React from 'react';
10 | import { storiesOf } from '@storybook/react';
11 | import { Clickable } from '../index';
12 |
13 | storiesOf('Clickable').add('simple', () => );
14 |
--------------------------------------------------------------------------------
/app/components/Clickable/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component tests should render and match the snapshot 1`] = `
4 |
5 |
6 |
7 |
11 |
15 | List of launches
16 |
17 |
18 |
19 |
20 |
21 | `;
22 |
--------------------------------------------------------------------------------
/app/components/Clickable/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Tests for Clickable
4 | *
5 | */
6 |
7 | import React from 'react';
8 |
9 | import { renderWithIntl } from '@utils/testUtils';
10 | import Clickable from '../index';
11 |
12 | describe(' component tests', () => {
13 | let clickSpy: jest.Mock;
14 | beforeAll(() => {
15 | clickSpy = jest.fn();
16 | });
17 | it('should render and match the snapshot', () => {
18 | const { baseElement } = renderWithIntl();
19 | expect(baseElement).toMatchSnapshot();
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/app/components/ErrorBoundary/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * ErrorBoundary
4 | *
5 | */
6 |
7 | import React, { PropsWithChildren } from 'react';
8 | import { translate } from '@app/utils';
9 |
10 | interface ErrorState {
11 | hasError: boolean;
12 | error: any;
13 | }
14 |
15 | class ErrorBoundary extends React.Component, ErrorState> {
16 | constructor(props: any) {
17 | super(props);
18 | this.state = { hasError: false, error: null };
19 | }
20 |
21 | static getDerivedStateFromError(error: any) {
22 | return { hasError: true, error };
23 | }
24 |
25 | componentDidCatch(error: any, errorInfo: any) {
26 | console.error(error, errorInfo);
27 | }
28 |
29 | render() {
30 | if (this.state.hasError) {
31 | // handle gracefully
32 | return {translate('something_went_wrong')}
;
33 | }
34 | return this.props.children;
35 | }
36 | }
37 |
38 | export default ErrorBoundary;
39 |
--------------------------------------------------------------------------------
/app/components/ErrorHandler/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import { Card } from 'antd';
5 | import { T, If } from '@components';
6 |
7 | const CustomCard = styled(Card)`
8 | && {
9 | margin: 20px 0;
10 | color: ${(props) => props.color};
11 | }
12 | `;
13 |
14 | interface ErrorHandlerTypes {
15 | loading: boolean;
16 | launchListError: string;
17 | }
18 |
19 | export function ErrorHandler({ loading, launchListError }: ErrorHandlerTypes) {
20 | if (!loading) {
21 | return (
22 | }>
23 |
24 |
25 |
26 |
27 | );
28 | }
29 | return null;
30 | }
31 |
32 | ErrorHandler.propTypes = {
33 | loading: PropTypes.bool,
34 | launchListError: PropTypes.string
35 | };
36 |
37 | export default ErrorHandler;
38 |
--------------------------------------------------------------------------------
/app/components/ErrorHandler/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 |
11 |
14 |
18 | Something went wrong
19 |
20 |
21 |
22 |
23 |
24 |
25 | `;
26 |
--------------------------------------------------------------------------------
/app/components/ErrorHandler/tests/index.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Tests for ErrorHandler
4 | *
5 | */
6 |
7 | import React from 'react';
8 | // import { fireEvent } from '@testing-library/dom'
9 | import { renderWithIntl } from '@utils/testUtils';
10 | import ErrorHandler from '../index';
11 |
12 | describe('', () => {
13 | const loading = false;
14 | const launchListError = 'something_went_wrong';
15 |
16 | it('should render and match the snapshot', () => {
17 | const { baseElement } = renderWithIntl();
18 | expect(baseElement).toMatchSnapshot();
19 | });
20 |
21 | it('should contain 1 ErrorHandler component if there is error present', () => {
22 | const { getAllByTestId } = renderWithIntl();
23 | expect(getAllByTestId('error-message').length).toBe(1);
24 | });
25 | it('should not show error when the page is loading', () => {
26 | const { getAllByTestId } = renderWithIntl();
27 | expect(() => getAllByTestId('error-message')).toThrowError();
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/app/components/For/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * For
4 | *
5 | */
6 |
7 | import React, { PropsWithChildren } from 'react';
8 | import Proptypes from 'prop-types';
9 | import styled from 'styled-components';
10 | import { Property } from 'csstype';
11 |
12 | type FlexContainerProps = {
13 | orientation?: Property.FlexDirection;
14 | children?: React.ReactNode;
15 | };
16 |
17 | const FlexContainer = styled.div`
18 | display: flex;
19 | flex-direction: ${(props) => props.orientation};
20 | `;
21 |
22 | type ForProps = {
23 | of?: T[];
24 | renderItem: (item: T, index: number) => React.ReactElement;
25 | ParentComponent?: React.FC;
26 | noParent?: boolean;
27 | };
28 |
29 | export function For({
30 | of,
31 | ParentComponent = (props: PropsWithChildren) => ,
32 | renderItem,
33 | noParent,
34 | ...props
35 | }: ForProps) {
36 | const items = () => of?.map((item, index) => ({ ...renderItem(item, index), key: index }));
37 | if (noParent) {
38 | ParentComponent = ({ children }: PropsWithChildren<{}>) => <>{children}>;
39 | }
40 | const list = () => (
41 |
42 | {items()}
43 |
44 | );
45 |
46 | return (of || []).length ? list() : null;
47 | }
48 |
49 | For.propTypes = {
50 | of: Proptypes.array,
51 | type: Proptypes.node,
52 | parent: Proptypes.object,
53 | renderItem: Proptypes.func.isRequired,
54 | noParent: Proptypes.bool,
55 | orientation: Proptypes.oneOf(['row', 'column'])
56 | };
57 |
58 | For.defaultProps = {
59 | orientation: 'row'
60 | };
61 | export default For;
62 |
--------------------------------------------------------------------------------
/app/components/For/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Tests for For
4 | *
5 | */
6 |
7 | import React from 'react';
8 | import { renderWithIntl } from '@utils/testUtils';
9 | import For from '../index';
10 |
11 | describe('', () => {
12 | it('should render the number of elements passed as props', () => {
13 | const items = ['a', 'b'];
14 | const { getAllByTestId } = renderWithIntl(
15 | {`item: ${item}`}
} />
16 | );
17 | expect(getAllByTestId('child').length).toEqual(items.length);
18 | });
19 |
20 | it('should render the number of elements passed as props and the parent should be a span', () => {
21 | const items = ['a', 'b'];
22 | const { getByTestId, getAllByTestId } = renderWithIntl(
23 | }
26 | renderItem={(item) => {`item: ${item}`}
}
27 | />
28 | );
29 |
30 | expect(getAllByTestId('parent-span').length).toEqual(1);
31 | expect(getByTestId('parent-span').children.length).toEqual(items.length);
32 | expect(getAllByTestId('child').length).toEqual(items.length);
33 | });
34 |
35 | it('should render the number of elements passed as props and should not add another layer of dom nesting', () => {
36 | const items = ['a', 'b'];
37 | const { findByTestId, getAllByTestId } = renderWithIntl(
38 | }
43 | renderItem={(item) => {`item: ${item}`}
}
44 | />
45 | );
46 |
47 | expect(findByTestId('parent-span')).resolves.toBeInTheDocument();
48 | expect(getAllByTestId('child').length).toEqual(items.length);
49 | });
50 |
51 | it('should not render anything when items is not passed', () => {
52 | const { findByTestId } = renderWithIntl(
53 | }
56 | renderItem={(item) => {`item: ${item}`}
}
57 | />
58 | );
59 |
60 | expect(findByTestId('parent-span')).resolves.not.toBeInTheDocument();
61 |
62 | const rendered = renderWithIntl(
63 | }
65 | renderItem={(item) => {`item: ${item}`}
}
66 | />
67 | );
68 |
69 | expect(rendered.findByTestId('parent-span')).resolves.not.toBeInTheDocument();
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/app/components/Header/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Header
4 | *
5 | */
6 | import React from 'react';
7 | import { Layout } from 'antd';
8 | import styled from 'styled-components';
9 | import { fonts, colors, media } from '@themes/index';
10 | import T from '@components/T';
11 | import logo from '@images/icon-512x512.png';
12 | import { Link } from 'react-router-dom';
13 |
14 | const StyledHeader = styled(Layout.Header)`
15 | && {
16 | &.ant-layout-header {
17 | padding: 0 1rem;
18 | height: ${(props) => props.theme.headerHeight};
19 | align-items: center;
20 | justify-content: center;
21 | background-color: ${colors.lightGreen};
22 | gap: 1rem;
23 | ${media.lessThan('mobile')`
24 | padding-left: ${(props) => props.theme.sidebarWidth}
25 | `}
26 | }
27 | display: flex;
28 | }
29 | `;
30 |
31 | const Logo = styled.img`
32 | height: 5rem;
33 | width: auto;
34 | object-fit: contain;
35 | ${media.lessThan('tablet')`
36 | height: 4rem;
37 | `}
38 | `;
39 |
40 | const Title = styled(T)`
41 | && {
42 | margin-bottom: 0;
43 | ${fonts.dynamicFontSize(fonts.size.xRegular, 1, 0.5)};
44 | display: flex;
45 | align-self: center;
46 | color: ${colors.secondaryText};
47 | }
48 | `;
49 |
50 | const Header: React.FC = () => {
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 | );
59 | };
60 |
61 | export default Header;
62 |
--------------------------------------------------------------------------------
/app/components/Header/stories/Header.stories.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Stories for Header
4 | *
5 | * @see https://github.com/storybookjs/storybook
6 | *
7 | */
8 |
9 | import React from 'react';
10 | import { storiesOf } from '@storybook/react';
11 | import { text } from '@storybook/addon-knobs';
12 | import Header from '../index';
13 |
14 | storiesOf('Header').add('simple', () => );
15 |
--------------------------------------------------------------------------------
/app/components/Header/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` should render and match the snapshot 1`] = `
4 |
5 |
6 |
26 |
27 |
28 | `;
29 |
--------------------------------------------------------------------------------
/app/components/Header/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Tests for Header
4 | *
5 | */
6 |
7 | import React from 'react';
8 | import { renderProvider } from '@utils/testUtils';
9 | import Header from '../index';
10 |
11 | describe('', () => {
12 | it('should render and match the snapshot', () => {
13 | const { baseElement } = renderProvider();
14 | expect(baseElement).toMatchSnapshot();
15 | });
16 |
17 | it('should contain logo', () => {
18 | const { getAllByAltText } = renderProvider();
19 | expect(getAllByAltText('logo').length).toBe(1);
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/app/components/If/index.tsx:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line
2 | import React from 'react';
3 | import Proptypes from 'prop-types';
4 |
5 | interface IfProps {
6 | condition?: boolean | number | string;
7 | otherwise?: React.ReactNode;
8 | children: React.ReactNode;
9 | }
10 |
11 | const If: React.FC = (props) => <>{props.condition ? props.children : props.otherwise}>;
12 |
13 | If.propTypes = {
14 | condition: Proptypes.oneOfType([Proptypes.bool, Proptypes.string, Proptypes.number]),
15 | otherwise: Proptypes.oneOfType([Proptypes.arrayOf(Proptypes.node), Proptypes.node]),
16 | children: Proptypes.oneOfType([Proptypes.arrayOf(Proptypes.node), Proptypes.node]).isRequired
17 | };
18 | If.defaultProps = {
19 | otherwise: null
20 | };
21 | export default If;
22 |
--------------------------------------------------------------------------------
/app/components/LaunchDetails/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` tests should render and match the snapshot 1`] = `
4 |
5 |
6 |
10 |
13 |

17 |
20 |
24 | CRS-21
25 |
26 |
30 | Details:
31 | SpaceX's 21st ISS resupply mission.
32 |
33 |
37 | Rocket
38 |
39 |
42 |
46 | Name: Falcon 9
47 |
48 |
52 | Type: FT
53 |
54 |
55 |
59 | Ships
60 |
61 |
64 |
68 | Name: Ship 1
69 |
70 |
74 | Type: Type 1
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | `;
83 |
--------------------------------------------------------------------------------
/app/components/LaunchDetails/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import LaunchDetails from '../index';
3 | import { renderProvider } from '@app/utils/testUtils';
4 |
5 | describe(' tests', () => {
6 | const launchDetails = {
7 | id: '1',
8 | loading: false,
9 | missionName: 'CRS-21',
10 | links: {
11 | flickrImages: ['https://farm9.staticflickr.com/8617/16789019815_f99a165dc5_o.jpg']
12 | },
13 | details: "SpaceX's 21st ISS resupply mission.",
14 | rocket: {
15 | rocketName: 'Falcon 9',
16 | rocketType: 'FT'
17 | },
18 | ships: [
19 | {
20 | name: 'Ship 1',
21 | type: 'Type 1'
22 | }
23 | ]
24 | };
25 | it('should render and match the snapshot', () => {
26 | const { baseElement } = renderProvider();
27 | expect(baseElement).toMatchSnapshot();
28 | });
29 |
30 | it('should render the mission name if it is available', () => {
31 | const { getByTestId } = renderProvider();
32 | expect(getByTestId('mission-name')).toBeInTheDocument();
33 | });
34 | it('should render the ship name if it is available', () => {
35 | const { getByTestId } = renderProvider();
36 | expect(getByTestId('ship-name')).toBeInTheDocument();
37 | });
38 | it('should render the ship type if it is available', () => {
39 | const { getByTestId } = renderProvider();
40 | expect(getByTestId('ship-type')).toBeInTheDocument();
41 | });
42 | it('should render the rocket name if it is available', () => {
43 | const { getByTestId } = renderProvider();
44 | expect(getByTestId('rocket-name')).toBeInTheDocument();
45 | });
46 | it('should render the rocket type if it is available', () => {
47 | const { getByTestId } = renderProvider();
48 | expect(getByTestId('rocket-type')).toBeInTheDocument();
49 | });
50 | it('should render the skeleton if loading is true', () => {
51 | launchDetails.loading = true;
52 | const { baseElement } = renderProvider();
53 | expect(baseElement.getElementsByClassName('ant-skeleton').length).toEqual(2);
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/app/components/LaunchItem/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import { Launch } from '@app/containers/HomeContainer/types';
3 | import { Button, Card } from 'antd';
4 | import PropTypes from 'prop-types';
5 | import styled from 'styled-components';
6 | import If from '@components/If';
7 | import { T } from '@components/T';
8 | import isEmpty from 'lodash-es/isEmpty';
9 | import { colors } from '@app/themes';
10 | import { GlobalOutlined } from '@ant-design/icons';
11 | import history from '@app/utils/history';
12 | import { format } from 'date-fns';
13 |
14 | const LaunchCard = styled(Card)`
15 | && {
16 | cursor: pointer;
17 | margin: 1rem 0;
18 | color: ${(props) => props.color};
19 | background-color: ${colors.cardBg};
20 | &:hover {
21 | box-shadow: inset 0 0 10px -5px rgba(0, 0, 0, 0.6);
22 | }
23 | }
24 | `;
25 |
26 | const WikiLink = styled(Button)`
27 | && {
28 | padding: 0;
29 | display: flex;
30 | align-items: center;
31 | color: ${colors.text};
32 | width: max-content;
33 | &:hover {
34 | opacity: 0.6;
35 | }
36 | }
37 | `;
38 |
39 | function LaunchItem({ missionName, launchDateUtc, links, id }: Launch) {
40 | const goToLaunch = () => history.push(`/launch/${id}`);
41 |
42 | const memoizedLaunchDate = useMemo(
43 | () => format(new Date(launchDateUtc), 'eee, do MMMM yyyy, hh:mm a'),
44 | [launchDateUtc]
45 | );
46 |
47 | return (
48 |
49 |
50 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | e.stopPropagation()}
70 | href={links.wikipedia}
71 | icon={}
72 | >
73 | Wikipedia
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
81 | LaunchItem.propTypes = {
82 | id: PropTypes.string,
83 | missionName: PropTypes.string,
84 | launchDateUtc: PropTypes.string,
85 | links: PropTypes.shape({
86 | wikipedia: PropTypes.string,
87 | flickrImages: PropTypes.arrayOf(PropTypes.string)
88 | })
89 | };
90 |
91 | export default LaunchItem;
92 |
--------------------------------------------------------------------------------
/app/components/LaunchItem/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` should render and match the snapshot 1`] = `
4 |
5 |
58 |
59 | `;
60 |
--------------------------------------------------------------------------------
/app/components/LaunchItem/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Launch } from '@app/containers/HomeContainer/types';
3 | import { renderProvider } from '@app/utils/testUtils';
4 | import LaunchItem from '..';
5 | import { fireEvent } from '@testing-library/react';
6 | import history from '@app/utils/history';
7 |
8 | describe('', () => {
9 | let launch: Launch;
10 |
11 | beforeAll(() => {
12 | launch = {
13 | id: '1',
14 | launchDateUtc: '2014-01-06T18:06:00',
15 | launchDateUnix: 123123123,
16 | missionName: 'Thaicom 6',
17 | links: {
18 | wikipedia: 'https://en.wikipedia.org/wiki/Thaicom_6',
19 | flickrImages: ['https://farm9.staticflickr.com/8617/16789019815_f99a165dc5_o.jpg']
20 | }
21 | };
22 | });
23 | it('should render and match the snapshot', () => {
24 | const { baseElement } = renderProvider();
25 | expect(baseElement).toMatchSnapshot();
26 | });
27 |
28 | it('should take us to launch details page if clicked on it', () => {
29 | const { getByTestId } = renderProvider();
30 | fireEvent.click(getByTestId('launch-item'));
31 | expect(history.location.pathname).toBe(`/launch/${launch.id}`);
32 | });
33 |
34 | it('should stopPropagation when clicked on link', () => {
35 | const prevHistoryLength = history.length;
36 | const { getByTestId } = renderProvider();
37 | fireEvent.click(getByTestId('wiki-link'));
38 | expect(history.length).toBe(prevHistoryLength);
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/app/components/LaunchList/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import { Launch } from '@containers/HomeContainer/types';
5 | import { get, isEmpty } from 'lodash-es';
6 | import { Card, Skeleton } from 'antd';
7 | import { If, T, For, LaunchItem } from '@components';
8 | import { colors } from '@app/themes';
9 |
10 | const CustomErrorCard = styled(Card)`
11 | && {
12 | color: ${colors.secondary};
13 | margin: 2rem;
14 | background-color: ${colors.secondaryText};
15 | }
16 | `;
17 |
18 | const Container = styled.div`
19 | && {
20 | display: flex;
21 | flex-direction: column;
22 | width: 100%;
23 | margin: 0 auto;
24 | background-color: ${colors.secondaryText};
25 | }
26 | `;
27 |
28 | interface LaunchListProps {
29 | launchData: { launches?: Launch[] };
30 | loading: boolean;
31 | }
32 |
33 | export function LaunchList({ launchData, loading }: LaunchListProps) {
34 | const launches = get(launchData, 'launches', []);
35 |
36 | return (
37 |
41 |
42 |
43 | }
44 | >
45 |
46 | } />
47 |
48 |
49 | );
50 | }
51 |
52 | LaunchList.propTypes = {
53 | launchData: PropTypes.shape({
54 | launches: PropTypes.arrayOf(
55 | PropTypes.shape({
56 | missionName: PropTypes.string,
57 | launchDateUtc: PropTypes.string,
58 | links: PropTypes.shape({
59 | wikipedia: PropTypes.string,
60 | flickrImages: PropTypes.array
61 | })
62 | })
63 | )
64 | }),
65 | loading: PropTypes.bool
66 | };
67 |
68 | export default memo(LaunchList);
69 |
--------------------------------------------------------------------------------
/app/components/LaunchList/stories/LaunchList.stories.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Stories for LaunchList
4 | *
5 | * @see https://github.com/storybookjs/storybook
6 | *
7 | */
8 |
9 | import React from 'react';
10 | import { storiesOf } from '@storybook/react';
11 | import { LaunchList } from '../index';
12 |
13 | const loading = false;
14 | const launchData = {
15 | launches: [
16 | {
17 | id: '1',
18 | launchDateUtc: '2014-01-06T14:06:00',
19 | missionName: 'Thaicom 6',
20 | links: {
21 | wikipedia: 'https://en.wikipedia.org/wiki/Thaicom_6',
22 | flickrImages: ['https://farm9.staticflickr.com/8617/16789019815_f99a165dc5_o.jpg']
23 | }
24 | }
25 | ]
26 | };
27 |
28 | storiesOf('LaunchList').add('simple', () => );
29 |
--------------------------------------------------------------------------------
/app/components/LaunchList/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` should render and match the snapshot 1`] = `
4 |
5 |
64 |
65 | `;
66 |
--------------------------------------------------------------------------------
/app/components/LaunchList/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Tests for LaunchList
4 | *
5 | */
6 |
7 | import React from 'react';
8 | import { renderProvider } from '@utils/testUtils';
9 | import LaunchList from '../index';
10 | import { LaunchData } from '@app/containers/HomeContainer/types';
11 |
12 | describe('', () => {
13 | const loading = false;
14 | const launchData: LaunchData = {
15 | launches: [
16 | {
17 | id: '1',
18 | launchDateUtc: '2014-01-06T18:06:00',
19 | launchDateUnix: 123123123,
20 | missionName: 'Thaicom 6',
21 | links: {
22 | wikipedia: 'https://en.wikipedia.org/wiki/Thaicom_6',
23 | flickrImages: ['https://farm9.staticflickr.com/8617/16789019815_f99a165dc5_o.jpg']
24 | }
25 | }
26 | ]
27 | };
28 | it('should render and match the snapshot', () => {
29 | const { baseElement } = renderProvider();
30 | expect(baseElement).toMatchSnapshot();
31 | });
32 | it('should show the fallbackMessage if luanchData is empty', () => {
33 | const message = 'No results found for the search term.';
34 | const { getByText } = renderProvider();
35 | expect(getByText(message)).toBeInTheDocument();
36 | });
37 | it('should render the list for the launches when data is available', () => {
38 | const { getByText } = renderProvider();
39 | expect(getByText('Thaicom 6')).toBeInTheDocument();
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/app/components/ProtectedRoute/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * ProtectedRoute
4 | *
5 | */
6 |
7 | import React, { ComponentType } from 'react';
8 | import { Redirect, Route, RouteComponentProps } from 'react-router-dom';
9 | import PropTypes from 'prop-types';
10 | import routeConstants from '@utils/routeConstants';
11 |
12 | interface ProtectedRouteProps {
13 | render: ComponentType;
14 | isLoggedIn: boolean;
15 | handleLogout?: () => void;
16 | path: string;
17 | exact: boolean;
18 | }
19 |
20 | const ProtectedRoute = ({ render: Component, isLoggedIn, handleLogout = () => {}, ...rest }: ProtectedRouteProps) => {
21 | const isUnprotectedRoute =
22 | Object.keys(routeConstants)
23 | .filter((key) => !routeConstants[key].isProtected)
24 | .map((key) => routeConstants[key].route)
25 | .includes(rest.path) && rest.exact;
26 |
27 | function handleRedirection(renderProps: RouteComponentProps) {
28 | let to;
29 | if (!isLoggedIn) {
30 | // user is not logged in
31 | if (!isUnprotectedRoute) {
32 | to = routeConstants.login.route;
33 | handleLogout();
34 | } else {
35 | // not logged in and trying to access an unprotected route so don't redirect
36 | return ;
37 | }
38 | } else {
39 | // user is logged in
40 | if (isUnprotectedRoute) {
41 | to = routeConstants.dashboard.route;
42 | } else {
43 | // logged in and accessing a protected route
44 | return ;
45 | }
46 | }
47 | return ;
48 | }
49 | return ;
50 | };
51 |
52 | ProtectedRoute.propTypes = {
53 | render: PropTypes.any,
54 | isLoggedIn: PropTypes.bool,
55 | isUserVerified: PropTypes.bool,
56 | handleLogout: PropTypes.func
57 | };
58 |
59 | export default ProtectedRoute;
60 |
--------------------------------------------------------------------------------
/app/components/ProtectedRoute/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` tests should render and match the snapshot 1`] = `
4 |
5 |
6 |
7 | Hello World
8 |
9 |
10 |
11 | `;
12 |
--------------------------------------------------------------------------------
/app/components/ProtectedRoute/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { renderProvider } from '@utils/testUtils';
3 | import ProtectedRoute from '../index';
4 | import * as protectedRouteFile from '../index';
5 | import '@testing-library/jest-dom';
6 |
7 | const RENDER_TEXT = 'Hello World';
8 |
9 | const HomeContainer = () => {RENDER_TEXT}
;
10 |
11 | jest.mock('@utils/routeConstants', () => {
12 | return {
13 | dashboard: {
14 | route: '/',
15 | isProtected: true
16 | },
17 | login: {
18 | route: '/login',
19 | isProtected: false
20 | }
21 | };
22 | });
23 |
24 | describe(' tests', () => {
25 | it('should render and match the snapshot', () => {
26 | const { baseElement } = renderProvider(
27 |
28 | );
29 | expect(baseElement).toMatchSnapshot();
30 | });
31 | it('should render the component if user logged in and access protected route', () => {
32 | const { getByText } = renderProvider(
33 |
34 | );
35 | expect(getByText(RENDER_TEXT)).toBeInTheDocument();
36 | });
37 | it('should not render component if user is not logged in with handleLogout', () => {
38 | const logoutSpy = jest.fn();
39 | renderProvider(
40 |
41 | );
42 | expect(logoutSpy).toHaveBeenCalled();
43 | });
44 |
45 | it('should not render component if user is not logged in without handleLogout', () => {
46 | const { queryByText } = renderProvider(
47 |
48 | );
49 | expect(queryByText(RENDER_TEXT)).toBeNull();
50 | });
51 |
52 | it('should render component , not logged in, unprotected route', () => {
53 | const { queryByText } = renderProvider(
54 |
55 | );
56 | expect(queryByText(RENDER_TEXT)).toBeInTheDocument();
57 | });
58 |
59 | it('should redirect to the dashboard if logged in and accessing login page(unprotected)', () => {
60 | const { queryByText } = renderProvider(
61 |
62 | );
63 | expect(queryByText(RENDER_TEXT)).toBeNull();
64 | });
65 | it('should call the default logout function', () => {
66 | const spy = jest.spyOn(protectedRouteFile.default.propTypes, 'handleLogout');
67 | renderProvider();
68 | expect(spy).toHaveBeenCalled();
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/app/components/ScrollToTop/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withRouter } from 'react-router';
3 | import { RouteComponentProps } from 'react-router-dom';
4 | import { compose } from 'redux';
5 |
6 | class ScrollToTop extends React.Component {
7 | componentDidUpdate(prevProps: RouteComponentProps) {
8 | if (this.props.location !== prevProps.location) {
9 | window.scrollTo(0, 0);
10 | }
11 | }
12 |
13 | render() {
14 | return this.props.children;
15 | }
16 | }
17 |
18 | export default compose(withRouter)(ScrollToTop);
19 |
--------------------------------------------------------------------------------
/app/components/Siderbar/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback } from 'react';
2 | import { colors } from '@app/themes';
3 | import { Button, Drawer, DrawerProps } from 'antd';
4 | import { Link } from 'react-router-dom';
5 | import styled from 'styled-components';
6 | import { CloseOutlined } from '@ant-design/icons';
7 | import icon from '@images/ion_rocket-sharp.svg';
8 | import menuIcon from '@images/menu.svg';
9 | import If from '@components/If';
10 | import useMedia from '@utils/useMedia';
11 |
12 | const SidebarWrapper = styled.div`
13 | position: relative;
14 | display: flex;
15 | `;
16 |
17 | const SidebarDrawer = styled(Drawer)`
18 | && {
19 | .ant-drawer-body {
20 | padding: ${(props) => props.theme.headerHeight} 0 0 0;
21 | background-color: ${colors.primary};
22 | width: ${(props) => props.theme.sidebarWidth};
23 | text-align: center;
24 | }
25 | .ant-drawer-close {
26 | top: 1rem;
27 | }
28 | }
29 | `;
30 |
31 | const SideBarStatic = styled.div`
32 | && {
33 | width: 6%;
34 | min-width: 4.5rem;
35 | max-width: 7rem;
36 | min-height: calc(100vh - ${(props) => props.theme.headerHeight});
37 | height: auto;
38 | background-color: ${colors.lightGreen};
39 | display: inline;
40 | text-align: center;
41 | }
42 | `;
43 |
44 | const RocketLogo = styled.img`
45 | && {
46 | margin-top: 1rem;
47 | object-fit: contain;
48 | }
49 | `;
50 |
51 | const MenuButton = styled(Button)`
52 | && {
53 | position: absolute;
54 | top: calc(${(props) => props.theme.headerHeight} / -2);
55 | left: calc(${(props) => props.theme.sidebarWidth} / 2);
56 | transform: translate(-50%, -50%);
57 | }
58 | `;
59 |
60 | const MenuImg = styled.img`
61 | width: 1.7rem;
62 | height: auto;
63 | object-fit: contain;
64 | `;
65 |
66 | const Sidebar: React.FC = () => {
67 | const [visible, setVisible] = useState(false);
68 | const { isMobile } = useMedia();
69 |
70 | const toggleSidebar = useCallback(() => setVisible((v) => !v), []);
71 | const sidebarProps: DrawerProps = isMobile
72 | ? {
73 | closeIcon: ,
74 | placement: 'left',
75 | visible,
76 | closable: true,
77 | onClose: toggleSidebar,
78 | width: 'max-content'
79 | }
80 | : {};
81 |
82 | const SidebarComponent = (props: DrawerProps) =>
83 | isMobile ? : ;
84 |
85 | return (
86 |
87 |
88 | }
95 | />
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | );
104 | };
105 |
106 | export default Sidebar;
107 |
--------------------------------------------------------------------------------
/app/components/Siderbar/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Sidebar from '..';
3 | import { renderProvider } from '@utils/testUtils';
4 | import { fireEvent, waitFor } from '@testing-library/react';
5 | import useScreenType from 'react-screentype-hook';
6 |
7 | jest.mock('react-screentype-hook', () => jest.fn());
8 |
9 | describe(' tests', () => {
10 | it('should contains menu icon in mobile screen', () => {
11 | (useScreenType as jest.Mock).mockImplementation(() => ({ isMobile: true }));
12 | const { getByTestId } = renderProvider();
13 | expect(getByTestId('menu-icon')).toBeInTheDocument();
14 | });
15 |
16 | it('should not contains menu icon for desktop screen', () => {
17 | (useScreenType as jest.Mock).mockImplementation(() => ({ isMobile: false }));
18 |
19 | const { queryByTestId } = renderProvider();
20 | expect(queryByTestId('menu-icon')).not.toBeInTheDocument();
21 | });
22 |
23 | it('should open drawer in mobile if clicked on mobile icon', async () => {
24 | (useScreenType as jest.Mock).mockImplementation(() => ({ isMobile: true }));
25 | const { getByTestId } = renderProvider();
26 | fireEvent.click(getByTestId('menu-icon'));
27 | await waitFor(() => expect(getByTestId('rocket-home-link')).toBeInTheDocument());
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/app/components/StyledContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | type StyledContainerProps = {
4 | maxWidth?: number;
5 | padding?: number;
6 | };
7 |
8 | const StyledContainer = styled.div`
9 | max-width: ${(props) => props.maxWidth || 62.5}rem;
10 | padding: ${(props) => props.maxWidth}rem;
11 | margin: 1rem auto;
12 | `;
13 |
14 | export default StyledContainer;
15 |
--------------------------------------------------------------------------------
/app/components/StyledContainer/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` tests should render and match the snapshot 1`] = `
4 |
5 |
12 |
13 | `;
14 |
--------------------------------------------------------------------------------
/app/components/StyledContainer/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { renderWithIntl } from '@app/utils/testUtils';
3 | import StyledContainer from '..';
4 |
5 | describe(' tests', () => {
6 | it('should render and match the snapshot', () => {
7 | const { baseElement } = renderWithIntl();
8 | expect(baseElement).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/app/components/T/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * T
4 | *
5 | */
6 |
7 | import React, { memo } from 'react';
8 | import styled, { FlattenSimpleInterpolation } from 'styled-components';
9 | import { Trans } from '@lingui/react';
10 | import PropTypes from 'prop-types';
11 | import If from '@components/If';
12 | import { fonts } from '@themes/index';
13 |
14 | interface StyledTextProps {
15 | marginBottom?: string | number;
16 | font?: () => FlattenSimpleInterpolation;
17 | }
18 |
19 | const StyledText = styled.p`
20 | && {
21 | ${(props) =>
22 | props.marginBottom &&
23 | `margin-bottom: ${typeof props.marginBottom === 'string' ? props.marginBottom : `${props.marginBottom}rem`};`}
24 | ${(props) => props.font && props.font()};
25 | }
26 | `;
27 |
28 | type FontStyleType = keyof typeof fonts.style;
29 |
30 | const getFontStyle = (type: FontStyleType) => fonts.style[type];
31 |
32 | interface TProps {
33 | type?: FontStyleType;
34 | text?: string;
35 | id: string;
36 | marginBottom?: string | number;
37 | values?: Record;
38 | }
39 |
40 | export const T = (props: TProps) => {
41 | const { type = 'standard', text, id, marginBottom, values = {}, ...otherProps } = props;
42 | return (
43 |
44 |
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | T.propTypes = {
52 | id: PropTypes.string,
53 | marginBottom: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
54 | values: PropTypes.object,
55 | text: PropTypes.string,
56 | type: PropTypes.oneOf(Object.keys(fonts.style))
57 | };
58 |
59 | T.defaultProps = {
60 | values: {},
61 | type: 'standard'
62 | };
63 |
64 | const TextComponent = memo(T);
65 | export default TextComponent;
66 |
--------------------------------------------------------------------------------
/app/components/T/stories/T.stories.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Stories for T
4 | *
5 | * @see https://github.com/storybookjs/storybook
6 | *
7 | */
8 |
9 | import React from 'react';
10 | import { storiesOf } from '@storybook/react';
11 | import { text } from '@storybook/addon-knobs';
12 |
13 | import { T } from '../index';
14 |
15 | storiesOf('T').add('simple', () => );
16 |
--------------------------------------------------------------------------------
/app/components/T/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` component tests should render and match the snapshot 1`] = `
4 |
5 |
13 |
14 | `;
15 |
--------------------------------------------------------------------------------
/app/components/T/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Tests for T
4 | *
5 | */
6 |
7 | import React from 'react';
8 | import { renderWithIntl, getComponentStyles } from '@utils/testUtils';
9 | import { T } from '../index';
10 |
11 | describe(' component tests', () => {
12 | it('should render and match the snapshot', () => {
13 | const { baseElement } = renderWithIntl();
14 | expect(baseElement).toMatchSnapshot();
15 | });
16 |
17 | it('should contain 1 T component', () => {
18 | const { getAllByTestId } = renderWithIntl();
19 | expect(getAllByTestId('t').length).toBe(1);
20 | });
21 |
22 | it('should contain render the text according to the id', () => {
23 | const { getAllByText } = renderWithIntl();
24 | expect(getAllByText(/List of launches/).length).toBe(1);
25 | });
26 |
27 | it('should have a margin-bottom of 5px when we pass marginBottom as 5', () => {
28 | const props = {
29 | marginBottom: 5,
30 | id: 'launches_list'
31 | };
32 | const styles = getComponentStyles(T, props);
33 | expect(styles.marginBottom).toBe(`${props.marginBottom}rem`);
34 | });
35 |
36 | it('should have a margin-bottom of 5px when we pass marginBottom as 5', () => {
37 | const props = {
38 | marginBottom: '5px',
39 | id: 'launches_list'
40 | };
41 | const styles = getComponentStyles(T, props);
42 | expect(styles.marginBottom).toBe(props.marginBottom);
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/app/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Clickable } from '@components/Clickable';
2 | export { default as ErrorBoundary } from '@components/ErrorBoundary';
3 | export { default as For } from '@components/For';
4 | export { default as Header } from '@components/Header';
5 | export { default as If } from '@components/If';
6 | export { default as LaunchList } from '@components/LaunchList';
7 | export { default as ProtectedRoute } from '@components/ProtectedRoute';
8 | export { default as ScrollToTop } from '@components/ScrollToTop';
9 | export { default as StyledContainer } from '@components/StyledContainer';
10 | export { default as T } from '@components/T';
11 | export { default as ErrorHandler } from '@components/ErrorHandler';
12 | export { default as LaunchItem } from '@components/LaunchItem';
13 |
--------------------------------------------------------------------------------
/app/configureStore.ts:
--------------------------------------------------------------------------------
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 { persistStore, persistReducer } from 'redux-persist';
8 | import immutableTransform from 'redux-persist-transform-immutable';
9 | import { composeWithDevTools } from '@redux-devtools/extension';
10 | import storage from 'redux-persist/lib/storage';
11 | import createReducer from './reducers';
12 | import { createInjectorsEnhancer } from 'redux-injectors';
13 |
14 | // redux persit configuration
15 | const persistConfig = {
16 | version: 1,
17 | transforms: [immutableTransform()],
18 | key: 'root',
19 | blacklist: ['home', 'launchDetails'],
20 | storage
21 | };
22 |
23 | const persistedReducer = persistReducer(persistConfig, createReducer());
24 |
25 | export type RootState = ReturnType;
26 |
27 | export default function configureStore(initialState: object) {
28 | let composeEnhancers = compose;
29 | const reduxSagaMonitorOptions = {};
30 |
31 | // If Redux Dev Tools and Saga Dev Tools Extensions are installed, enable them
32 | /* istanbul ignore next */
33 | if (process.env.NODE_ENV !== 'production' && typeof window === 'object') {
34 | /* eslint-disable no-underscore-dangle */
35 | composeEnhancers = composeWithDevTools as typeof compose;
36 | // NOTE: Uncomment the code below to restore support for Redux Saga
37 | // Dev Tools once it supports redux-saga version 1.x.x
38 | // if (window.__SAGA_MONITOR_EXTENSION__)
39 | // reduxSagaMonitorOptions = {
40 | // sagaMonitor: window.__SAGA_MONITOR_EXTENSION__,
41 | // };
42 | /* eslint-enable */
43 | }
44 |
45 | const sagaMiddleware = createSagaMiddleware(reduxSagaMonitorOptions);
46 |
47 | // Create the store with two middlewares
48 | // 1. sagaMiddleware: Makes redux-sagas work
49 | // 2. routerMiddleware: Syncs the location/URL path to the state
50 | const middlewares = [sagaMiddleware];
51 | const enhancers = [applyMiddleware(...middlewares)];
52 | const runSaga = sagaMiddleware.run;
53 | const injectEnhancer = createInjectorsEnhancer({
54 | createReducer,
55 | runSaga
56 | });
57 |
58 | const store = createStore(persistedReducer, initialState, composeEnhancers(...enhancers, injectEnhancer));
59 | const persistor = persistStore(store);
60 |
61 | // Extensions
62 | // store.runSaga = sagaMiddleware.run;
63 | // store.injectedReducers = {}; // Reducer registry
64 | // store.injectedSagas = {}; // Saga Registry
65 |
66 | // Make reducers hot reloadable, see http://mxs.is/googmo
67 | /* istanbul ignore next */
68 | // if (module.hot) {
69 | // module.hot.accept('./reducers', () => {
70 | // store.replaceReducer(createReducer(store.injectedReducers));
71 | // });
72 | // }
73 | return { store, persistor };
74 | }
75 |
--------------------------------------------------------------------------------
/app/containers/App/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * App.js
4 | *
5 | * This component is the skeleton around the actual pages, and should only
6 | * contain code that should be seen on all pages. (e.g. navigation bar)
7 | *
8 | */
9 | import React from 'react';
10 | import GlobalStyle from '@app/global-styles';
11 | import { routeConfig } from '@app/routeConfig';
12 | import { Layout } from 'antd';
13 | import map from 'lodash-es/map';
14 | import { withRouter } from 'react-router';
15 | import { Route, Switch } from 'react-router-dom';
16 | import { compose } from 'redux';
17 | import styled, { ThemeProvider } from 'styled-components';
18 | import For from '@components/For';
19 | import Header from '@components/Header';
20 | import { colors } from '@themes/index';
21 | import Sidebar from '@app/components/Siderbar';
22 | import { HEADER_HEIGHT, MIN_SIDEBAR_WIDTH } from '@app/utils/constants';
23 |
24 | const theme = {
25 | fg: colors.primary,
26 | bg: colors.secondaryText,
27 | headerHeight: HEADER_HEIGHT,
28 | sidebarWidth: MIN_SIDEBAR_WIDTH
29 | };
30 |
31 | const CustomLayout = styled(Layout)`
32 | && {
33 | flex-direction: row;
34 | }
35 | `;
36 |
37 | export function App() {
38 | return (
39 |
40 |
41 |
42 |
43 |
44 | }
46 | of={map(Object.keys(routeConfig))}
47 | renderItem={(routeKey, index) => {
48 | const Component = routeConfig[routeKey].component;
49 | return (
50 | {
55 | const updatedProps = {
56 | ...props,
57 | ...routeConfig[routeKey].props
58 | };
59 | return ;
60 | }}
61 | />
62 | );
63 | }}
64 | />
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
72 | export default compose(withRouter)(App);
73 |
--------------------------------------------------------------------------------
/app/containers/App/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` container tests should render and match the snapshot 1`] = `
4 |
5 |
25 |
53 |
54 | `;
55 |
--------------------------------------------------------------------------------
/app/containers/App/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { renderProvider } from '@utils/testUtils';
3 | import App from '@containers/App';
4 | import { BrowserRouter } from 'react-router-dom';
5 | import { waitFor } from '@testing-library/react';
6 |
7 | describe(' container tests', () => {
8 | it('should render and match the snapshot', async () => {
9 | const { container } = renderProvider(
10 |
11 |
12 |
13 | );
14 | await waitFor(() => expect(container.textContent).toContain('Wednesday Solutions'));
15 | await waitFor(() => expect(container).toMatchSnapshot());
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/Loadable.ts:
--------------------------------------------------------------------------------
1 | import loadable from '@utils/loadable';
2 |
3 | export default loadable(() => import('./index'));
4 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/queries.ts:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-boost';
2 |
3 | export const GET_LAUNCHES = gql`
4 | query launches($missionName: String, $order: String, $limit: Int, $offset: Int) {
5 | launches(
6 | find: { mission_name: $missionName }
7 | sort: "launch_date_utc"
8 | order: $order
9 | limit: $limit
10 | offset: $offset
11 | ) {
12 | id
13 | launch_date_utc
14 | launch_date_unix
15 | mission_name
16 | links {
17 | wikipedia
18 | flickr_images
19 | }
20 | }
21 | }
22 | `;
23 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/reducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import get from 'lodash-es/get';
3 | import { prepare } from '@app/utils';
4 |
5 | export const initialState = {
6 | loading: false,
7 | launchData: {},
8 | launchListError: null
9 | };
10 |
11 | const homeSlice = createSlice({
12 | name: 'home',
13 | initialState,
14 | reducers: {
15 | requestGetLaunchList: {
16 | reducer: (state) => {
17 | state.loading = true;
18 | },
19 | prepare
20 | },
21 | successGetLaunchList(state, action) {
22 | state.launchListError = null;
23 | state.launchData = action.payload;
24 | state.loading = false;
25 | },
26 | failureGetLaunchList(state, action) {
27 | state.launchListError = get(action.payload, 'message', 'something_went_wrong');
28 | state.loading = false;
29 | }
30 | }
31 | });
32 |
33 | export const { requestGetLaunchList, successGetLaunchList, failureGetLaunchList } = homeSlice.actions;
34 |
35 | export default homeSlice.reducer;
36 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/saga.ts:
--------------------------------------------------------------------------------
1 | import { getQueryResponse } from '@app/utils/graphqlUtils';
2 | import { GET_LAUNCHES } from './queries';
3 | import { LAUNCH_PER_PAGE } from './usePaginate';
4 | import { put, call, takeLatest } from 'redux-saga/effects';
5 | import { requestGetLaunchList, successGetLaunchList, failureGetLaunchList } from './reducer';
6 | import { LaunchesResponse, LaunchesAction } from './types';
7 |
8 | export function* getLaunchList(action: LaunchesAction): Generator {
9 | const { missionName, order, page } = action.payload;
10 | const response = yield call(getQueryResponse, GET_LAUNCHES, {
11 | missionName,
12 | order,
13 | sort: 'launch_date_utc',
14 | limit: LAUNCH_PER_PAGE,
15 | offset: (page - 1) * LAUNCH_PER_PAGE
16 | });
17 | const { data, ok, error } = response;
18 | if (ok) {
19 | yield put(successGetLaunchList(data));
20 | } else {
21 | yield put(failureGetLaunchList(error));
22 | }
23 | }
24 |
25 | // Individual exports for testing
26 | export default function* homeContainerSaga() {
27 | yield takeLatest(requestGetLaunchList.toString(), getLaunchList);
28 | }
29 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/selectors.ts:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import get from 'lodash-es/get';
3 | import { initialState } from './reducer';
4 |
5 | export const selectHomeContainerDomain = (state: any) => state.home || initialState;
6 |
7 | export const selectLaunchData = () =>
8 | createSelector(selectHomeContainerDomain, (substate) => get(substate, 'launchData'));
9 | export const selectLaunchListError = () =>
10 | createSelector(selectHomeContainerDomain, (substate) => get(substate, 'launchListError'));
11 |
12 | export const selectLoading = () => createSelector(selectHomeContainerDomain, (substate) => get(substate, 'loading'));
13 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` tests should render and match the snapshot 1`] = `
4 |
5 |
155 |
156 | `;
157 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/tests/mockData.js:
--------------------------------------------------------------------------------
1 | export const LAUNCHES = [
2 | {
3 | id: '1',
4 | missionName: 'Thaicom 6',
5 | links: {
6 | flickrImages: ['image1', 'image2'],
7 | wikipedia: 'https://en.wikipedia.org/wiki/Thaicom_6'
8 | },
9 | launchDateUtc: '2014-01-06T14:06:00-04:00',
10 | launchDateUnix: 1389031560
11 | },
12 | {
13 | id: '1',
14 | missionName: 'AsiaSat 6',
15 | links: {
16 | flickrImages: ['image1', 'image2'],
17 | wikipedia: 'https://en.wikipedia.org/wiki/AsiaSat_6'
18 | },
19 | launchDateUtc: '2014-09-07T01:00:00-04:00',
20 | launchDateUnix: 1410066000
21 | },
22 | {
23 | id: '1',
24 | missionName: 'OG-2 Mission 2',
25 | links: {
26 | flickrImages: ['image1', 'image2'],
27 | wikipedia: 'https://en.wikipedia.org/wiki/Falcon_9_flight_20'
28 | },
29 | launchDateUtc: '2015-12-22T21:29:00-04:00',
30 | launchDateUnix: 1450747740
31 | },
32 | {
33 | id: '1',
34 | missionName: 'FalconSat',
35 | links: {
36 | flickrImages: ['image1', 'image2'],
37 | wikipedia: 'https://en.wikipedia.org/wiki/DemoSat'
38 | },
39 | launchDateUtc: '2006-03-25T10:30:00+12:00',
40 | launchDateUnix: 1143239400
41 | },
42 | {
43 | id: '1',
44 | missionName: 'CRS-1',
45 | links: {
46 | flickrImages: ['image1', 'image2'],
47 | wikipedia: 'https://en.wikipedia.org/wiki/SpaceX_CRS-1'
48 | },
49 | launchDateUtc: '2012-10-08T20:35:00-04:00',
50 | launchDateUnix: 1349656500
51 | },
52 | {
53 | id: '1',
54 | missionName: 'CASSIOPE',
55 | links: {
56 | flickrImages: ['image1', 'image2'],
57 | wikipedia: 'https://en.wikipedia.org/wiki/CASSIOPE'
58 | },
59 | launchDateUtc: '2013-09-29T09:00:00-07:00',
60 | launchDateUnix: 1380470400
61 | },
62 | {
63 | id: '1',
64 | missionName: 'ABS-3A / Eutelsat 115W B',
65 | links: {
66 | flickrImages: ['image1', 'image2'],
67 | wikipedia: 'https://en.wikipedia.org/wiki/ABS-3A'
68 | },
69 | launchDateUtc: '2015-03-02T23:50:00-04:00',
70 | launchDateUnix: 1425268200
71 | },
72 | {
73 | id: '1',
74 | missionName: 'COTS 1',
75 | links: {
76 | flickrImages: ['image1', 'image2'],
77 | wikipedia: 'https://en.wikipedia.org/wiki/SpaceX_COTS_Demo_Flight_1'
78 | },
79 | launchDateUtc: '2010-12-08T11:43:00-04:00',
80 | launchDateUnix: 1291822980
81 | },
82 | {
83 | id: '1',
84 | missionName: 'TürkmenÄlem 52°E / MonacoSAT',
85 | links: {
86 | flickrImages: ['image1', 'image2'],
87 | wikipedia: 'https://en.wikipedia.org/wiki/T%C3%BCrkmen%C3%84lem_52%C2%B0E_/_MonacoSAT'
88 | },
89 | launchDateUtc: '2015-04-27T19:03:00-04:00',
90 | launchDateUnix: 1430175780
91 | },
92 | {
93 | id: '1',
94 | missionName: 'CRS-11',
95 | links: {
96 | flickrImages: ['image1', 'image2'],
97 | wikipedia: 'https://en.wikipedia.org/wiki/SpaceX_CRS-11'
98 | },
99 | launchDateUtc: '2017-06-03T17:07:00-04:00',
100 | launchDateUnix: 1496524020
101 | },
102 | {
103 | id: '1',
104 | missionName: 'Iridium NEXT Mission 1',
105 | links: {
106 | flickrImages: ['image1', 'image2'],
107 | wikipedia: 'https://en.wikipedia.org/wiki/Iridium_satellite_constellation#Next-generation_constellation'
108 | },
109 | launchDateUtc: '2017-01-14T10:54:00-07:00',
110 | launchDateUnix: 1484416440
111 | }
112 | ];
113 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/tests/reducer.test.ts:
--------------------------------------------------------------------------------
1 | import { LaunchData } from '../types';
2 | import homeReducer, {
3 | initialState,
4 | requestGetLaunchList,
5 | successGetLaunchList,
6 | failureGetLaunchList
7 | } from '../reducer';
8 |
9 | describe('HomContainer reducer tests', () => {
10 | let state: typeof initialState;
11 | const payload = {
12 | missionName: 'Asia',
13 | sort: 'asc',
14 | page: 1
15 | };
16 | beforeEach(() => {
17 | state = initialState;
18 | });
19 |
20 | it('should return the initial state', () => {
21 | expect(
22 | homeReducer(undefined, {
23 | type: undefined
24 | })
25 | ).toEqual(state);
26 | });
27 |
28 | it('should return the initial state when an action of type REQUEST_GET_LAUNCH_LIST is dispatched', () => {
29 | const expectedResult = { ...state, loading: true };
30 | expect(homeReducer(state, requestGetLaunchList(payload))).toEqual(expectedResult);
31 | });
32 |
33 | it('should ensure that the launch data is present when SUCCESS_GET_LAUNCH_LIST is dispatched', () => {
34 | const launchData: LaunchData = {
35 | launches: [
36 | {
37 | id: '1',
38 | missionName: 'Sample Launch',
39 | launchDateUtc: '2017-01-14T10:54:00-07:00',
40 | launchDateUnix: 123123123,
41 | links: {
42 | wikipedia: 'wiki link',
43 | flickrImages: ['image1', 'image2']
44 | }
45 | }
46 | ]
47 | };
48 |
49 | const expectedResult = { ...state, launchData };
50 | expect(homeReducer(state, successGetLaunchList(launchData))).toEqual(expectedResult);
51 | });
52 |
53 | it('should ensure that the launchListError has some data when failureGetLaunchList is dispatched', () => {
54 | const launchListError = {
55 | message: 'something went wrong'
56 | };
57 | const expectedResult = { ...state, launchListError: launchListError.message };
58 | expect(homeReducer(state, failureGetLaunchList(launchListError))).toEqual(expectedResult);
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/tests/saga.test.ts:
--------------------------------------------------------------------------------
1 | import { takeLatest, call, put } from 'redux-saga/effects';
2 | import { apiResponseGenerator } from '@utils/testUtils';
3 | import homeContainerSaga, { getLaunchList } from '../saga';
4 | import { getQueryResponse } from '@app/utils/graphqlUtils';
5 | import { GET_LAUNCHES } from '../queries';
6 | import { LAUNCH_PER_PAGE } from '../usePaginate';
7 | import { failureGetLaunchList, requestGetLaunchList, successGetLaunchList } from '../reducer';
8 |
9 | describe('HomeContainer saga tests', () => {
10 | const generator = homeContainerSaga();
11 | let getLaunchListGenerator = getLaunchList({
12 | type: 'SOME ACTION',
13 | payload: {
14 | missionName: null,
15 | order: null,
16 | page: 1
17 | }
18 | });
19 |
20 | it('should start task to watch for REQUEST_GET_LAUNCH_LIST action', () => {
21 | expect(generator.next().value).toEqual(takeLatest(requestGetLaunchList.toString(), getLaunchList));
22 | });
23 |
24 | it('should ensure that the action FAILURE_GET_LAUNCH_LIST is dispatched when the api call fails', () => {
25 | const res = getLaunchListGenerator.next().value;
26 | expect(res).toEqual(
27 | call(getQueryResponse, GET_LAUNCHES, {
28 | missionName: null,
29 | order: null,
30 | sort: 'launch_date_utc',
31 | limit: LAUNCH_PER_PAGE,
32 | offset: 0
33 | })
34 | );
35 | const errorResponse = {
36 | message: 'There was an error while fetching launch informations.'
37 | };
38 | expect(getLaunchListGenerator.next(apiResponseGenerator(false, {}, errorResponse)).value).toEqual(
39 | put(failureGetLaunchList(errorResponse))
40 | );
41 | });
42 |
43 | it('should ensure that the action SUCCESS_GET_LAUNCH_LIST is dispatched when the api call succeeds', () => {
44 | getLaunchListGenerator = getLaunchList({
45 | type: 'SOME_ACTION',
46 | payload: {
47 | missionName: null,
48 | order: null,
49 | page: 1
50 | }
51 | });
52 | const res = getLaunchListGenerator.next().value;
53 | expect(res).toEqual(
54 | call(getQueryResponse, GET_LAUNCHES, {
55 | missionName: null,
56 | order: null,
57 | sort: 'launch_date_utc',
58 | limit: LAUNCH_PER_PAGE,
59 | offset: 0
60 | })
61 | );
62 | const apiResponse = {
63 | launches: [
64 | {
65 | id: '1',
66 | missionName: 'sampleName',
67 | launchDateUtc: '2017-01-14T10:54:00-07:00',
68 | launchDateUnix: 123121232,
69 | links: {
70 | wikipedia: 'wiki link',
71 | flickrImages: ['image1', 'image2']
72 | }
73 | }
74 | ]
75 | };
76 | expect(getLaunchListGenerator.next(apiResponseGenerator(true, apiResponse)).value).toEqual(
77 | put(successGetLaunchList(apiResponse))
78 | );
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/tests/selectors.test.ts:
--------------------------------------------------------------------------------
1 | import { selectHomeContainerDomain, selectLaunchData, selectLaunchListError, selectLoading } from '../selectors';
2 | import { initialState } from '../reducer';
3 | import { RootState } from '@app/configureStore';
4 | import { Launch } from '../types';
5 | describe('HomeContainer selector tests', () => {
6 | let mockedState: RootState;
7 | let launchData: { launches?: Partial[] };
8 | let launchListError: Object;
9 | let loading: boolean;
10 |
11 | beforeEach(() => {
12 | launchData = { launches: [{}] };
13 | launchListError = 'There was some error while fetching the launch details';
14 | loading = false;
15 |
16 | mockedState = {
17 | home: {
18 | launchData,
19 | launchListError,
20 | loading
21 | }
22 | };
23 | });
24 |
25 | it('should select the launchListError', () => {
26 | const launchErrorSelector = selectLaunchListError();
27 | expect(launchErrorSelector(mockedState)).toEqual(launchListError);
28 | });
29 |
30 | it('should select the global state', () => {
31 | const selector = selectHomeContainerDomain(mockedState);
32 | expect(selector).toEqual(mockedState.home);
33 | });
34 |
35 | it('should select the global state from initial state if state.home is not defined', () => {
36 | const selector = selectHomeContainerDomain(initialState);
37 | expect(selector).toEqual(initialState);
38 | });
39 | it('should select loading', () => {
40 | const launchLoadingSelector = selectLoading();
41 | expect(launchLoadingSelector(mockedState)).toEqual(loading);
42 | });
43 | it('should select the launchData', () => {
44 | const launchDataSelector = selectLaunchData();
45 | expect(launchDataSelector(mockedState)).toEqual(launchData);
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/types.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction } from 'redux';
2 | import { GqlQueryReponse } from '@app/utils/graphqlUtils';
3 | export interface Launch {
4 | id: string;
5 | missionName: string;
6 | launchDateUtc: string;
7 | launchDateUnix: number;
8 | links: {
9 | wikipedia: string;
10 | flickrImages: Array;
11 | };
12 | }
13 |
14 | export interface LaunchData {
15 | launches?: Launch[];
16 | }
17 | export interface RequestLaunchesActionPayload {
18 | missionName: string | null;
19 | order: string | null; // 'asc' | 'desc'
20 | page: number; // starts from 1
21 | }
22 |
23 | export type LaunchesActionCreator = (payload: RequestLaunchesActionPayload) => AnyAction;
24 |
25 | export interface HomeContainerProps {
26 | dispatchLaunchList: LaunchesActionCreator;
27 | launchData: LaunchData;
28 | launchListError?: string;
29 | loading: boolean;
30 | }
31 |
32 | export type LaunchesResponse = GqlQueryReponse<{ launches?: Launch[] }>;
33 |
34 | export interface LaunchesAction {
35 | payload: { missionName: any; order: any; page: any };
36 | type: string;
37 | }
38 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/usePaginate.ts:
--------------------------------------------------------------------------------
1 | import { setQueryParam } from '@app/utils';
2 | import history from '@app/utils/history';
3 | import { LaunchData } from './types';
4 |
5 | export const LAUNCH_PER_PAGE = 6;
6 |
7 | function usePaginate(launchData: LaunchData) {
8 | const pageQp = Number(new URLSearchParams(history.location.search).get('page'));
9 | const page = Number.isNaN(pageQp) || pageQp < 1 ? 1 : pageQp;
10 |
11 | const handlePrev = () => setQueryParam({ param: 'page', value: page - 1 });
12 | const handleNext = () => setQueryParam({ param: 'page', value: page + 1 });
13 | const resetPage = () => setQueryParam({ param: 'page', value: 1 });
14 |
15 | return {
16 | page,
17 | hasPrevPage: launchData.launches?.length && page !== 1,
18 | hasNextPage: launchData.launches?.length && launchData.launches.length >= LAUNCH_PER_PAGE,
19 | handlePrev,
20 | handleNext,
21 | resetPage
22 | };
23 | }
24 |
25 | export default usePaginate;
26 |
--------------------------------------------------------------------------------
/app/containers/HomeContainer/useSort.ts:
--------------------------------------------------------------------------------
1 | import { setQueryParam } from '@app/utils';
2 | import history from '@app/utils/history';
3 |
4 | export default function useSort() {
5 | const orderQp = new URLSearchParams(history.location.search).get('order');
6 | const order = [null, 'asc', 'desc'].includes(orderQp) ? orderQp : null;
7 |
8 | const handleDateSort = (value: string) => setQueryParam({ param: 'order', value });
9 | const handleClearSort = () => setQueryParam({ param: 'order', deleteParam: true });
10 |
11 | return {
12 | order,
13 | handleClearSort,
14 | handleDateSort
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/app/containers/LanguageProvider/index.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | *
3 | * LanguageProvider
4 | *
5 | * this component connects the redux state language locale to the
6 | * IntlProvider component and i18n messages (loaded from `app/translations`)
7 | */
8 |
9 | import React, { PropsWithChildren } from 'react';
10 | import PropTypes from 'prop-types';
11 | import { connect } from 'react-redux';
12 | import { createSelector } from 'reselect';
13 | import { i18n } from '@lingui/core';
14 | import { I18nProvider } from '@lingui/react';
15 | import { makeSelectLocale } from './selectors';
16 |
17 | export interface LanguageProviderProps {
18 | locale: string;
19 | messages: Record;
20 | }
21 |
22 | export function LanguageProvider({ locale, messages, children }: PropsWithChildren) {
23 | const localizedMessages = messages[locale];
24 |
25 | i18n.load(locale, localizedMessages);
26 | i18n.activate(locale);
27 |
28 | return {React.Children.only(children)};
29 | }
30 |
31 | LanguageProvider.propTypes = {
32 | locale: PropTypes.string,
33 | messages: PropTypes.object,
34 | children: PropTypes.element.isRequired
35 | };
36 |
37 | LanguageProvider.defaultProps = {
38 | locale: 'en'
39 | };
40 |
41 | const mapStateToProps = createSelector(makeSelectLocale(), (locale) => ({
42 | locale
43 | }));
44 |
45 | export default connect(mapStateToProps, null)(LanguageProvider);
46 |
--------------------------------------------------------------------------------
/app/containers/LanguageProvider/reducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import { DEFAULT_LOCALE } from '@app/i18n';
3 |
4 | export const initialState = {
5 | locale: DEFAULT_LOCALE
6 | };
7 |
8 | const languageProviderSlice = createSlice({
9 | name: 'language',
10 | initialState,
11 | reducers: {
12 | changeLocale(state, action) {
13 | state.locale = action.payload;
14 | }
15 | }
16 | });
17 |
18 | export const { changeLocale } = languageProviderSlice.actions;
19 |
20 | export default languageProviderSlice.reducer;
21 |
--------------------------------------------------------------------------------
/app/containers/LanguageProvider/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from '@app/configureStore';
2 | import { createSelector } from 'reselect';
3 | import { initialState } from './reducer';
4 |
5 | /**
6 | * Direct selector to the languageToggle state domain
7 | */
8 | const selectLanguage = (state: Partial) => state.languageProvider || initialState;
9 |
10 | /**
11 | * Select the language locale
12 | */
13 |
14 | const makeSelectLocale = () => createSelector(selectLanguage, (languageState) => languageState.locale);
15 |
16 | export { selectLanguage, makeSelectLocale };
17 |
--------------------------------------------------------------------------------
/app/containers/LanguageProvider/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import { Provider } from 'react-redux';
4 | import { Trans } from '@lingui/react';
5 | import configureStore, { RootState } from '@app/configureStore';
6 | import { translationMessages, DEFAULT_LOCALE } from '@app/i18n';
7 | import { Store } from 'redux';
8 | import ConnectedLanguageProvider, { LanguageProvider } from '../index';
9 |
10 | describe(' tests', () => {
11 | it('should render its children', () => {
12 | const children = Test
;
13 | const { container } = render(
14 |
15 | {children}
16 |
17 | );
18 | expect(container.firstChild).not.toBeNull();
19 | });
20 | });
21 |
22 | describe(' tests', () => {
23 | let store: Store;
24 |
25 | beforeAll(() => {
26 | store = configureStore({}).store;
27 | });
28 |
29 | it('should render the default language messages', () => {
30 | const message = 'Ships';
31 | const { queryByText } = render(
32 |
33 |
34 |
35 |
36 |
37 | );
38 | expect(queryByText(message)).not.toBeNull();
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/app/containers/LanguageProvider/tests/reducer.test.ts:
--------------------------------------------------------------------------------
1 | import languageProviderReducer, { initialState, changeLocale } from '../reducer';
2 |
3 | describe('Tests for LanguageProvider actions', () => {
4 | let mockedState: { locale: string };
5 | beforeEach(() => {
6 | mockedState = initialState;
7 | });
8 |
9 | it('returns the initial state', () => {
10 | expect(
11 | languageProviderReducer(undefined, {
12 | type: undefined
13 | })
14 | ).toEqual(mockedState);
15 | });
16 |
17 | it('changes the locale', () => {
18 | const locale = 'de';
19 | mockedState = { ...mockedState, locale };
20 | expect(
21 | languageProviderReducer(undefined, {
22 | type: changeLocale.toString(),
23 | payload: locale
24 | })
25 | ).toEqual(mockedState);
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/app/containers/LanguageProvider/tests/selectors.test.ts:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import { makeSelectLocale, selectLanguage } from '../selectors';
3 | import { initialState } from '../reducer';
4 | import { RootState } from '@app/configureStore';
5 | import { DEFAULT_LOCALE } from '@app/i18n';
6 |
7 | describe('Tests for LanguageProvider selectors', () => {
8 | const globalState = {
9 | locale: DEFAULT_LOCALE
10 | };
11 | let mockedState: Partial;
12 | beforeAll(() => {
13 | mockedState = {
14 | language: globalState
15 | };
16 | });
17 | it('should select the global state', () => {
18 | expect(selectLanguage(mockedState)).toEqual(globalState);
19 | });
20 |
21 | it('should select the global state', () => {
22 | mockedState = {};
23 | expect(selectLanguage(mockedState)).toEqual(initialState);
24 | });
25 |
26 | it('should return the selector', () => {
27 | const expectedResult = createSelector(selectLanguage, (initialState) => initialState.locale);
28 | expect(JSON.stringify(makeSelectLocale())).toEqual(JSON.stringify(expectedResult));
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/Loadable.ts:
--------------------------------------------------------------------------------
1 | import loadable from '@utils/loadable';
2 |
3 | export default loadable(() => import('./index'));
4 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useEffect } from 'react';
2 | import LaunchDetailsComponent from '@app/components/LaunchDetails';
3 | import PropTypes from 'prop-types';
4 | import { connect } from 'react-redux';
5 | import { Helmet } from 'react-helmet';
6 | import { useParams } from 'react-router-dom';
7 | import { createStructuredSelector } from 'reselect';
8 | import { compose } from 'redux';
9 | import { injectSaga } from 'redux-injectors';
10 | import { ErrorHandler, If } from '@components';
11 | import NotFound from '@containers/NotFoundPage';
12 | import { selectLaunch, selectLaunchError, selectLoading } from './selectors';
13 | import saga from './saga';
14 | import { requestGetLaunch } from './reducer';
15 | import { LaunchDetailsProps } from './types';
16 |
17 | export function LaunchDetails({ launch, launchError, loading, dispatchLaunch }: LaunchDetailsProps) {
18 | const params = useParams<{ launchId?: string }>();
19 |
20 | useEffect(() => {
21 | if (params.launchId) {
22 | dispatchLaunch(params.launchId);
23 | }
24 | }, [params]);
25 |
26 | return (
27 |
28 |
29 | Launch Details
30 |
31 |
32 | {launch && }
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | LaunchDetails.propTypes = {
42 | launch: PropTypes.object,
43 | launchError: PropTypes.string,
44 | loading: PropTypes.bool.isRequired,
45 | dispatchLaunch: PropTypes.func.isRequired
46 | };
47 |
48 | const mapStateToProps = createStructuredSelector({
49 | launch: selectLaunch(),
50 | launchError: selectLaunchError(),
51 | loading: selectLoading()
52 | });
53 |
54 | export function mapDispatchToProps(dispatch: Function) {
55 | return {
56 | dispatchLaunch: (launchId: string) => dispatch(requestGetLaunch(launchId))
57 | };
58 | }
59 |
60 | const withConnect = connect(mapStateToProps, mapDispatchToProps);
61 |
62 | export default compose(withConnect, memo, injectSaga({ key: 'launchDetails', saga }))(LaunchDetails);
63 |
64 | export const LaunchDetailsTest = LaunchDetails;
65 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/queries.ts:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-boost';
2 |
3 | export const GET_LAUNCH = gql`
4 | query GetLaunch($launchId: ID!) {
5 | launch(id: $launchId) {
6 | id
7 | mission_name
8 | details
9 | rocket {
10 | rocket_name
11 | rocket_type
12 | }
13 | ships {
14 | name
15 | type
16 | }
17 | links {
18 | flickr_images
19 | }
20 | }
21 | }
22 | `;
23 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/reducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import get from 'lodash-es/get';
3 | import { prepare } from '@app/utils';
4 |
5 | export const initialState = {
6 | launch: {},
7 | loading: true,
8 | launchError: null
9 | };
10 |
11 | const launchDetailsSlice = createSlice({
12 | name: 'launchDetails',
13 | initialState,
14 | reducers: {
15 | requestGetLaunch: {
16 | reducer: (state) => {
17 | state.loading = true;
18 | },
19 | prepare
20 | },
21 | successGetLaunch(state, action) {
22 | state.loading = false;
23 | state.launchError = null;
24 | state.launch = get(action.payload, 'launch', {});
25 | },
26 | failureGetLaunch(state, action) {
27 | state.loading = false;
28 | state.launch = {};
29 | state.launchError = get(action.payload, 'message', 'something_went_wrong');
30 | }
31 | }
32 | });
33 |
34 | export const { requestGetLaunch, successGetLaunch, failureGetLaunch } = launchDetailsSlice.actions;
35 |
36 | export default launchDetailsSlice.reducer;
37 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/saga.ts:
--------------------------------------------------------------------------------
1 | import { getQueryResponse } from '@app/utils/graphqlUtils';
2 | import { AnyAction } from 'redux';
3 | import { call, takeLatest, put } from 'redux-saga/effects';
4 | import { GET_LAUNCH } from './queries';
5 | import { requestGetLaunch, successGetLaunch, failureGetLaunch } from './reducer';
6 | import { LaunchResponse } from './types';
7 |
8 | // Individual exports for testing
9 | export function* getLaunch(action: AnyAction): Generator {
10 | const response = yield call(getQueryResponse, GET_LAUNCH, { launchId: action.payload });
11 |
12 | const { ok, data, error } = response;
13 |
14 | if (ok) {
15 | yield put(successGetLaunch(data));
16 | } else {
17 | yield put(failureGetLaunch(error));
18 | }
19 | }
20 |
21 | export default function* launchDetailsSaga() {
22 | yield takeLatest(requestGetLaunch.toString(), getLaunch);
23 | }
24 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/selectors.ts:
--------------------------------------------------------------------------------
1 | import get from 'lodash-es/get';
2 | import { createSelector } from 'reselect';
3 | import { initialState } from './reducer';
4 |
5 | /**
6 | * Direct selector to the launchDetails state domain
7 | */
8 |
9 | const selectLaunchDetailsDomain = (state: any) => state.launchDetails || initialState;
10 |
11 | const selectLaunch = () => createSelector(selectLaunchDetailsDomain, (substate) => get(substate, 'launch'));
12 | const selectLoading = () => createSelector(selectLaunchDetailsDomain, (substate) => get(substate, 'loading'));
13 | const selectLaunchError = () => createSelector(selectLaunchDetailsDomain, (substate) => get(substate, 'launchError'));
14 |
15 | export default selectLaunchDetailsDomain;
16 | export { selectLaunchDetailsDomain, selectLaunch, selectLoading, selectLaunchError };
17 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` container tests should render and match the snapshot 1`] = `
4 |
5 |
6 |
7 |
10 |
14 | Oops, this page doesn't exist!
15 |
16 |
22 |
25 |

29 |
30 |
31 |
35 |
36 |
37 |
38 | `;
39 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Tests for LaunchDetails
4 | *
5 | *
6 | */
7 |
8 | import React from 'react';
9 | import { renderProvider, timeout } from '@utils/testUtils';
10 | // import { fireEvent } from '@testing-library/dom'
11 | import { LaunchDetailsTest as LaunchDetails, mapDispatchToProps } from '..';
12 | import { LaunchDetailsProps } from '../types';
13 | import history from '@app/utils/history';
14 | import { requestGetLaunch } from '../reducer';
15 |
16 | describe(' container tests', () => {
17 | let submitSpy: jest.Mock;
18 | let defaultProps: LaunchDetailsProps;
19 |
20 | beforeEach(() => {
21 | submitSpy = jest.fn();
22 | defaultProps = {
23 | loading: false,
24 | launch: null,
25 | dispatchLaunch: submitSpy
26 | };
27 | });
28 | it('should render and match the snapshot', () => {
29 | const { baseElement } = renderProvider();
30 | expect(baseElement).toMatchSnapshot();
31 | });
32 |
33 | it('should call dispatchLaunch if launchId param is present', async () => {
34 | history.location.pathname = '/1';
35 | renderProvider(, { path: '/:launchId' });
36 | await timeout(500);
37 | expect(submitSpy).toBeCalledWith('1');
38 | });
39 |
40 | it('should mapDispatchToProps works as expected', () => {
41 | const dispatchSpy = jest.fn();
42 | const props = mapDispatchToProps(dispatchSpy);
43 | props.dispatchLaunch('1');
44 | expect(dispatchSpy).toBeCalledWith(requestGetLaunch('1'));
45 | });
46 | it('should not render the launchDetails if there is no data', () => {
47 | const { getByTestId } = renderProvider();
48 | expect(() => getByTestId('launch-details')).toThrowError();
49 | });
50 | it('should render the launchDetails if there is data', () => {
51 | const props = {
52 | loading: false,
53 | launch: {
54 | id: '1',
55 | missionName: 'CRS-21',
56 | links: {
57 | flickrImages: ['https://farm9.staticflickr.com/8617/16789019815_f99a165dc5_o.jpg']
58 | },
59 | details: "SpaceX's 21st ISS resupply mission.",
60 | rocket: {
61 | rocketName: 'Falcon 9',
62 | rocketType: 'FT'
63 | },
64 | ships: [
65 | {
66 | name: 'Ship 1',
67 | type: 'Type 1'
68 | }
69 | ]
70 | },
71 | dispatchLaunch: submitSpy
72 | };
73 |
74 | const { getByTestId } = renderProvider();
75 |
76 | expect(getByTestId('launch-details')).toBeInTheDocument();
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/tests/reducer.test.ts:
--------------------------------------------------------------------------------
1 | // import produce from 'immer'
2 | import launchDetailsReducer, { initialState, requestGetLaunch, successGetLaunch, failureGetLaunch } from '../reducer';
3 |
4 | /* eslint-disable default-case, no-param-reassign */
5 | describe('LaunchDetails reducer tests', () => {
6 | let state: typeof initialState;
7 | beforeEach(() => {
8 | state = initialState;
9 | });
10 |
11 | it('should return the initial state', () => {
12 | expect(
13 | launchDetailsReducer(undefined, {
14 | type: 'SOME ACTION'
15 | })
16 | ).toEqual(state);
17 | });
18 |
19 | it('should return the update the state when an action of type requestGetLaunch is dispatched', () => {
20 | const expectedResult = { ...state, loading: true };
21 | expect(launchDetailsReducer(state, requestGetLaunch(1))).toEqual(expectedResult);
22 | });
23 |
24 | it('should update launch when successGetLaunch is dispatched', () => {
25 | const launch = {
26 | id: 1,
27 | missionName: 'Mission 1'
28 | };
29 | const expectedResult = {
30 | loading: false,
31 | launchError: null,
32 | launch
33 | };
34 | expect(launchDetailsReducer(state, successGetLaunch({ launch }))).toEqual(expectedResult);
35 | });
36 |
37 | it('should update launchError when failureGetLaunch is dispatched', () => {
38 | const error = {
39 | message: 'Unable to fetch launch'
40 | };
41 | const expectedResult = {
42 | loading: false,
43 | launchError: error.message,
44 | launch: {}
45 | };
46 | expect(launchDetailsReducer(state, failureGetLaunch(error))).toEqual(expectedResult);
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/tests/saga.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Test launchDetails sagas
3 | */
4 |
5 | /* eslint-disable redux-saga/yield-effects */
6 | import { getQueryResponse } from '@app/utils/graphqlUtils';
7 | import { apiResponseGenerator } from '@app/utils/testUtils';
8 | import { call, put, takeLatest } from 'redux-saga/effects';
9 | import { GET_LAUNCH } from '../queries';
10 | import { failureGetLaunch, requestGetLaunch, successGetLaunch } from '../reducer';
11 | import launchDetailsSaga, { getLaunch } from '../saga';
12 |
13 | describe('LaunchDetails saga tests', () => {
14 | const generator = launchDetailsSaga();
15 |
16 | it('should start task to watch for REQUEST_GET_LAUNCH action', () => {
17 | expect(generator.next().value).toEqual(takeLatest(requestGetLaunch.toString(), getLaunch));
18 | });
19 |
20 | it('should give call effect on first yield of getLaunch generator ', () => {
21 | const launchGenerator = getLaunch({ type: requestGetLaunch.toString(), payload: 1 });
22 | expect(launchGenerator.next().value).toEqual(call(getQueryResponse, GET_LAUNCH, { launchId: 1 }));
23 | });
24 |
25 | it('should ensure that SUCCESS_GET_LAUNCH action is dispatched when call effect succeeds', () => {
26 | const launch = {
27 | id: '10',
28 | missionName: 'CRS-2',
29 | details: 'Last launch of the original Falcon 9 v1.0 launch vehicle',
30 | rocket: {
31 | rocketName: 'Falcon 9',
32 | rocketType: 'v1.0'
33 | },
34 | ships: [
35 | {
36 | name: 'American Islander',
37 | type: 'Cargo'
38 | }
39 | ],
40 | links: {
41 | flickrImages: []
42 | }
43 | };
44 | const launchGenerator = getLaunch({ type: requestGetLaunch.toString(), payload: 1 });
45 | launchGenerator.next();
46 | expect(launchGenerator.next(apiResponseGenerator(true, { ...launch })).value).toEqual(
47 | put(successGetLaunch(launch))
48 | );
49 | });
50 |
51 | it('should ensure that SUCCESS_GET_LAUNCH action is dispatched when call effect fails', () => {
52 | const error = {
53 | message: 'some error'
54 | };
55 | const launchGenerator = getLaunch({ type: requestGetLaunch.toString(), payload: 1 });
56 | launchGenerator.next();
57 | expect(launchGenerator.next(apiResponseGenerator(false, undefined, error)).value).toEqual(
58 | put(failureGetLaunch(error))
59 | );
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/tests/selectors.test.ts:
--------------------------------------------------------------------------------
1 | import { initialState } from '@app/containers/LaunchDetails/reducer';
2 | import { selectLaunch, selectLaunchDetailsDomain, selectLaunchError, selectLoading } from '../selectors';
3 |
4 | describe('LaunchDetails selector tests', () => {
5 | let mockedState: { launchDetails: any };
6 | let launch: {};
7 | let launchError: string;
8 | let loading: boolean;
9 |
10 | beforeAll(() => {
11 | launch = {};
12 | launchError = 'new LaunchError("_^_")';
13 | loading = false;
14 |
15 | mockedState = {
16 | launchDetails: {
17 | launch,
18 | launchError,
19 | loading
20 | }
21 | };
22 | });
23 |
24 | it('should select the launchDetails state', () => {
25 | expect(selectLaunchDetailsDomain(mockedState)).toEqual(mockedState.launchDetails);
26 | });
27 |
28 | it('should select initial state when launchDetails not preset in state', () => {
29 | expect(selectLaunchDetailsDomain({})).toEqual(initialState);
30 | });
31 |
32 | it('should select launch', () => {
33 | const launchSelector = selectLaunch();
34 | expect(launchSelector(mockedState)).toEqual(launch);
35 | });
36 |
37 | it('should select launchError', () => {
38 | const launchErrorSelector = selectLaunchError();
39 | expect(launchErrorSelector(mockedState)).toEqual(launchError);
40 | });
41 |
42 | it('should select loading', () => {
43 | const loadingSelector = selectLoading();
44 | expect(loadingSelector(mockedState)).toEqual(loading);
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/app/containers/LaunchDetails/types.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction } from 'redux';
2 | import { GqlQueryReponse } from '@app/utils/graphqlUtils';
3 | export interface LaunchDetails {
4 | id: string;
5 | missionName: string;
6 | details: string;
7 | rocket: {
8 | rocketName: string;
9 | rocketType: string;
10 | };
11 | ships: {
12 | name: string;
13 | type: string;
14 | }[];
15 | links: {
16 | flickrImages?: string[];
17 | };
18 | }
19 |
20 | export type LaunchResponse = GqlQueryReponse;
21 |
22 | export interface LaunchDetailsProps {
23 | launch: LaunchDetails | null;
24 | launchError?: string;
25 | loading: boolean;
26 | dispatchLaunch: (launchId: string) => AnyAction;
27 | }
28 |
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/Loadable.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Asynchronously loads the component for NotFoundPage
3 | */
4 |
5 | import loadable from '@utils/loadable';
6 |
7 | export default loadable(() => import('./index'));
8 |
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * NotFoundPage
3 | *
4 | * This is the page we show when the user visits a url that doesn't have a route
5 | *
6 | */
7 |
8 | import React from 'react';
9 | import styled from 'styled-components';
10 | import { Image } from 'antd';
11 | import notFoundImage from '@images/undraw_page_not_found_re_e9o6.svg';
12 | import { T } from '@components';
13 | import { colors } from '@app/themes';
14 | import history from '@utils/history';
15 |
16 | const NotFoundImage = styled(Image)`
17 | && {
18 | height: 60%;
19 | width: 60%;
20 | margin: 25%;
21 | }
22 | `;
23 |
24 | const NotFoundContainer = styled.div`
25 | && {
26 | display: flex;
27 | flex-direction: column;
28 | max-width: 30%;
29 | justify-content: center;
30 | align-items: center;
31 | margin: 5% auto;
32 | }
33 | `;
34 |
35 | const CustomButton = styled.button`
36 | && {
37 | background-color: ${colors.primary};
38 | border-radius: 8px;
39 | padding: 4px;
40 | color: ${colors.secondaryText};
41 | cursor: pointer;
42 | }
43 | `;
44 | export default function NotFound() {
45 | return (
46 |
47 |
48 | history.push('/')}>
49 | Go Back
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/tests/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` tests should render and match the snapshot 1`] = ` `;
4 |
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { fireEvent } from '@testing-library/react';
3 | import { I18nProvider } from '@lingui/react';
4 | import { i18n } from '@lingui/core';
5 |
6 | import NotFoundPage from '../index';
7 | import history from '@app/utils/history';
8 | import { renderWithIntl } from '@app/utils/testUtils';
9 |
10 | describe(' tests', () => {
11 | it('should render and match the snapshot', () => {
12 | const {
13 | container: { firstChild }
14 | } = renderWithIntl(
15 |
16 |
17 |
18 | );
19 | expect(firstChild).toMatchSnapshot();
20 | });
21 |
22 | it('should take the user back to the homePage if the go back button is clicked', () => {
23 | const { getByTestId } = renderWithIntl(
24 |
25 |
26 |
27 | );
28 | const spy = jest.spyOn(history, 'push');
29 | fireEvent.click(getByTestId('back-button'));
30 | expect(spy).toBeCalled();
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/app/global-styles.ts:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 | import { colors } from '@app/themes';
3 |
4 | const GlobalStyle = createGlobalStyle`
5 | html,
6 | body {
7 | height: 100vh;
8 | width: 100vw;
9 | overflow-x: hidden;
10 | margin: 0;
11 | padding: 0;
12 | }
13 |
14 | body {
15 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
16 | }
17 |
18 | body.fontLoaded {
19 | font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
20 | }
21 |
22 | #app {
23 | background-color: ${colors.secondaryText};
24 | min-height: 100%;
25 | min-width: 100%;
26 | }
27 |
28 | p,
29 | span,
30 | button,
31 | label {
32 | font-family: 'Rubik', sans-serif;
33 | line-height: 1.5em;
34 | margin-bottom: 0;
35 | }
36 | `;
37 |
38 | export default GlobalStyle;
39 |
--------------------------------------------------------------------------------
/app/i18n.ts:
--------------------------------------------------------------------------------
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 |
11 | const enTranslationMessages = require('./translations/en.json');
12 |
13 | export const DEFAULT_LOCALE = 'en';
14 |
15 | // prettier-ignore
16 | export const appLocales = [
17 | 'en',
18 | ];
19 |
20 | type Locale = string;
21 | type MessageRecord = Record;
22 |
23 | export const formatTranslationMessages = (locale: Locale, messages: MessageRecord) => {
24 | const defaultFormattedMessages: MessageRecord =
25 | locale !== DEFAULT_LOCALE ? formatTranslationMessages(DEFAULT_LOCALE, enTranslationMessages) : {};
26 | const flattenFormattedMessages = (formattedMessages: MessageRecord, key: string) => {
27 | const formattedMessage: string =
28 | !messages[key] && locale !== DEFAULT_LOCALE ? defaultFormattedMessages[key] : messages[key];
29 | return Object.assign(formattedMessages, { [key]: formattedMessage });
30 | };
31 | return Object.keys(messages).reduce(flattenFormattedMessages, {});
32 | };
33 |
34 | export const translationMessages = {
35 | en: formatTranslationMessages('en', enTranslationMessages)
36 | };
37 |
--------------------------------------------------------------------------------
/app/images/ArrowDown.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/images/ArrowUp.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/images/ArrowUpDown.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/app/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wednesday-solutions/react-graphql-ts-template/211dd8904aaf0a9019a45aea7131b395a4376764/app/images/favicon.ico
--------------------------------------------------------------------------------
/app/images/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wednesday-solutions/react-graphql-ts-template/211dd8904aaf0a9019a45aea7131b395a4376764/app/images/icon-512x512.png
--------------------------------------------------------------------------------
/app/images/ion_rocket-sharp.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/app/images/kai-pilger-Ef6iL87-vOA-unsplash.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wednesday-solutions/react-graphql-ts-template/211dd8904aaf0a9019a45aea7131b395a4376764/app/images/kai-pilger-Ef6iL87-vOA-unsplash.jpg
--------------------------------------------------------------------------------
/app/images/menu.svg:
--------------------------------------------------------------------------------
1 |
2 |
21 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | React Template
16 |
17 |
18 |
19 |
20 |
21 |
25 |
26 |
27 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/app/reducers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Combine all reducers in this file and export the combined reducers.
3 | */
4 |
5 | import { combineReducers } from 'redux';
6 |
7 | import languageProvider from '@containers/LanguageProvider/reducer';
8 | import home from '@containers/HomeContainer/reducer';
9 | import launchDetails from '@containers/LaunchDetails/reducer';
10 |
11 | /**
12 | * Merges the main reducer with the router state and dynamically injected reducers
13 | */
14 | export default function createReducer(injectedReducer = {}) {
15 | const rootReducer = combineReducers({
16 | ...injectedReducer,
17 | languageProvider,
18 | home,
19 | launchDetails
20 | });
21 |
22 | return rootReducer;
23 | }
24 |
--------------------------------------------------------------------------------
/app/routeConfig.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import NotFound from '@containers/NotFoundPage/Loadable';
3 | import HomeContainer from '@containers/HomeContainer/Loadable';
4 | import LaunchDetails from '@containers/LaunchDetails/Loadable';
5 | import routeConstants, { RouteConstant } from '@utils/routeConstants';
6 |
7 | type RouteConfig = Record } & Partial>;
8 |
9 | export const routeConfig: RouteConfig = {
10 | home: {
11 | component: HomeContainer,
12 | ...routeConstants.home
13 | },
14 | launch: {
15 | component: LaunchDetails,
16 | ...routeConstants.launch
17 | },
18 | notFoundPage: {
19 | component: NotFound,
20 | route: '/'
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/app/tests/i18n.test.ts:
--------------------------------------------------------------------------------
1 | import { formatTranslationMessages } from '../i18n';
2 |
3 | jest.mock('../translations/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 = '#2F4858';
9 | const lightGreen = '#607274';
10 | const text = '#4D3D0C';
11 | const secondaryText = '#FFFFFF';
12 | const tertiaryText = '#4D3D0C';
13 | const secondary = '#b0b0b0';
14 | const success = '#28a745';
15 | const error = '#dc3545';
16 | const gotoStories = '#1890ff';
17 | const cardBg = '#EFF1F3';
18 |
19 | const colors = {
20 | transparent: 'rgba(0,0,0,0)',
21 | // Example colors:
22 | text,
23 | primary,
24 | secondary,
25 | success,
26 | error,
27 | secondaryText,
28 | gotoStories,
29 | cardBg,
30 | lightGreen,
31 | tertiaryText,
32 | theme: {
33 | lightMode: {
34 | primary,
35 | secondary
36 | },
37 | darkMode: {
38 | primary: secondary,
39 | secondary: primary
40 | }
41 | }
42 | };
43 | module.exports = colors;
44 |
--------------------------------------------------------------------------------
/app/themes/fonts.ts:
--------------------------------------------------------------------------------
1 | import { css, FlattenSimpleInterpolation } from 'styled-components';
2 | import { media } from '@themes/index';
3 |
4 | // sizes
5 |
6 | export const dynamicFontSize = (font: () => FlattenSimpleInterpolation, desktopDelta = 0, tabletDelta = 0) => css`
7 | ${font()}
8 | ${media.greaterThan('tablet')`font-size: ${
9 | tabletDelta + parseInt((font()[0]! as string).replace('font-size:', '').replace('rem;', '').replace(/\s+/g, ''))
10 | }rem;`}
11 | ${media.greaterThan('desktop')`font-size: ${
12 | desktopDelta + parseInt((font()[0]! as string).replace('font-size:', '').replace('rem;', '').replace(/\s+/g, ''))
13 | }rem;`}
14 | `;
15 | const regular = () => css`
16 | font-size: 1rem;
17 | `;
18 |
19 | const xRegular = () => css`
20 | font-size: 1.125rem;
21 | `;
22 | const small = () => css`
23 | font-size: 0.875rem;
24 | `;
25 | const big = () => css`
26 | font-size: 1.25rem;
27 | `;
28 | const large = () => css`
29 | font-size: 1.5rem;
30 | `;
31 | const extraLarge = () => css`
32 | font-size: 2rem;
33 | `;
34 |
35 | // weights
36 | const light = () => css`
37 | font-weight: light;
38 | `;
39 | const bold = () => css`
40 | font-weight: bold;
41 | `;
42 |
43 | const normal = () => css`
44 | font-weight: normal;
45 | `;
46 |
47 | // styles
48 | const heading = () => css`
49 | ${large()}
50 | ${bold()}
51 | `;
52 |
53 | const subheading = () => css`
54 | ${big()}
55 | ${bold()}
56 | `;
57 |
58 | const smallBoldText = () => css`
59 | ${small()}
60 | ${bold()}
61 | `;
62 |
63 | const standard = () => css`
64 | ${regular()}
65 | ${normal()}
66 | `;
67 |
68 | const subText = () => css`
69 | ${small()}
70 | ${normal()}
71 | `;
72 |
73 | export default {
74 | dynamicFontSize,
75 | size: {
76 | regular,
77 | small,
78 | big,
79 | large,
80 | extraLarge,
81 | xRegular
82 | },
83 | style: {
84 | heading,
85 | subheading,
86 | standard,
87 | subText,
88 | smallBoldText
89 | },
90 | weights: {
91 | light,
92 | bold,
93 | normal
94 | }
95 | };
96 |
--------------------------------------------------------------------------------
/app/themes/index.ts:
--------------------------------------------------------------------------------
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.ts:
--------------------------------------------------------------------------------
1 | import { generateMedia } from 'styled-media-query';
2 |
3 | export const screenBreakPoints = {
4 | MOBILE: 390,
5 | TABLET: 768,
6 | DESKTOP: 992,
7 | LARGE_DESKTOP: 1400
8 | };
9 |
10 | const media = generateMedia({
11 | mobile: `${screenBreakPoints.MOBILE / 16}em`,
12 | tablet: `${screenBreakPoints.TABLET / 16}em`,
13 | desktop: `${screenBreakPoints.DESKTOP / 16}em`
14 | });
15 |
16 | export default media;
17 |
--------------------------------------------------------------------------------
/app/themes/styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import { colors } from '@themes/index';
3 |
4 | export const configureFlex = ({
5 | direction = 'row',
6 | justifyContent = 'center',
7 | alignItems = 'center',
8 | alignContent = 'center',
9 | flexBasis = 0,
10 | flexGrow = 1,
11 | flexShrink = 0
12 | }) => css`
13 | ${direction === 'row' ? row() : column()}
14 | flex-direction: ${direction};
15 | justify-content: ${justifyContent};
16 | align-items: ${alignItems};
17 | align-content: ${alignContent};
18 | flex-basis: ${flexBasis};
19 | flex-grow: ${flexGrow};
20 | flex-shrink: ${flexShrink};
21 | `;
22 |
23 | const row = () => css`
24 | display: flex;
25 | flex: 1;
26 | flex-direction: row;
27 | `;
28 | const column = () => css`
29 | display: flex;
30 | flex: 1;
31 | flex-direction: column;
32 | `;
33 |
34 | const rowCenter = () => css`
35 | ${configureFlex({
36 | direction: 'row',
37 | justifyContent: 'center',
38 | alignItems: 'center',
39 | alignContent: 'center'
40 | })};
41 | `;
42 |
43 | const unequalColumns = () => css`
44 | ${configureFlex({
45 | direction: 'column',
46 | justifyContent: '',
47 | alignItems: '',
48 | alignContent: '',
49 | flexBasis: 0,
50 | flexGrow: 0,
51 | flexShrink: 0
52 | })};
53 | `;
54 |
55 | const height = (height = 4) => css`
56 | height: ${height}rem;
57 | `;
58 |
59 | const top = (marginTop = 0) =>
60 | css`
61 | margin-top: ${marginTop}rem;
62 | `;
63 |
64 | const bottom = (marginBottom = 0) =>
65 | css`
66 | margin-bottom: ${marginBottom}rem;
67 | `;
68 |
69 | const left = (marginLeft = 0) =>
70 | css`
71 | margin-left: ${marginLeft}rem;
72 | `;
73 |
74 | const right = (marginRight = 0) =>
75 | css`
76 | margin-right: ${marginRight}rem;
77 | `;
78 |
79 | const vertical = (verticalMargin = 0) => css`
80 | margin-top: ${verticalMargin}rem;
81 | margin-bottom: ${verticalMargin}rem;
82 | `;
83 |
84 | const horizontal = (horizontalMargin = 0) => css`
85 | margin-left: ${horizontalMargin}rem;
86 | margin-right: ${horizontalMargin}rem;
87 | `;
88 |
89 | const borderRadiusBottom = (bottomRadius = 0) => css`
90 | border-bottom-left-radius: ${bottomRadius}px;
91 | border-bottom-right-radius: ${bottomRadius}px;
92 | `;
93 |
94 | const borderRadiusTop = (topRadius = 0) => css`
95 | border-top-left-radius: ${topRadius}px;
96 | border-top-right-radius: ${topRadius}px;
97 | `;
98 |
99 | const borderRadius = (radius: string | number) =>
100 | css`
101 | border-radius: ${radius + `${typeof radius === `string` ? `;` : `px`}`};
102 | `;
103 |
104 | const borderWithRadius = ({ width = 1, type = 'solid', color = '#ccc', radius = 0 }) =>
105 | css`
106 | border: ${width}px ${type} ${color};
107 | ${borderRadius(radius)}
108 | `;
109 |
110 | const boxShadow = ({ hOffset = 0, vOffset = 0, blur = 0, spread = 0, color = '#ccc' }) =>
111 | css`
112 | box-shadow: ${hOffset}px ${vOffset}px ${blur}px ${spread}px ${color};
113 | `;
114 |
115 | const primaryBackgroundColor = () =>
116 | css`
117 | background-color: ${colors.primary};
118 | `;
119 |
120 | const zIndex = (z = 1) => css`
121 | z-index: ${z};
122 | `;
123 |
124 | const textEllipsis = (width = '200px') => css`
125 | white-space: nowrap;
126 | overflow: hidden;
127 | width: ${width};
128 | text-overflow: ellipsis;
129 | `;
130 | export default {
131 | height,
132 | zIndex,
133 | textEllipsis,
134 | margin: {
135 | top,
136 | bottom,
137 | right,
138 | left,
139 | vertical,
140 | horizontal
141 | },
142 | borderWithRadius,
143 | borderRadius,
144 | borderRadiusBottom,
145 | borderRadiusTop,
146 | primaryBackgroundColor,
147 | flexConfig: {
148 | row,
149 | column,
150 | rowCenter,
151 | unequalColumns
152 | },
153 | boxShadow
154 | };
155 |
--------------------------------------------------------------------------------
/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.ts:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import fonts, { dynamicFontSize } from '../fonts';
3 | import media from '../media';
4 |
5 | describe('fonts', () => {
6 | it('should have the correct font-size', () => {
7 | expect(fonts.size.small()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:0.875rem')]));
8 | expect(fonts.size.regular()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:1rem;')]));
9 | expect(fonts.size.big()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:1.25rem;')]));
10 | expect(fonts.size.large()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:1.5rem;')]));
11 | expect(fonts.size.extraLarge()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:2rem;')]));
12 | });
13 | it('should have the correct font-weight', () => {
14 | expect(fonts.weights.light()).toEqual(expect.arrayContaining([expect.stringContaining('font-weight:light;')]));
15 | expect(fonts.weights.bold()).toEqual(expect.arrayContaining([expect.stringContaining('font-weight:bold;')]));
16 | expect(fonts.weights.normal()).toEqual(expect.arrayContaining([expect.stringContaining('font-weight:normal;')]));
17 | });
18 | it('should have the correct font-weight and font-size', () => {
19 | expect(fonts.style.heading()).toEqual(expect.arrayContaining([expect.stringContaining('font-weight:bold;')]));
20 | expect(fonts.style.heading()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:1.5rem;')]));
21 | expect(fonts.style.subheading()).toEqual(expect.arrayContaining([expect.stringContaining('font-weight:bold;')]));
22 | expect(fonts.style.subheading()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:1.25rem;')]));
23 | expect(fonts.style.standard()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:1rem;')]));
24 | expect(fonts.style.standard()).toEqual(expect.arrayContaining([expect.stringContaining('font-weight:normal;')]));
25 | expect(fonts.style.subText()).toEqual(expect.arrayContaining([expect.stringContaining('font-size:0.875rem;')]));
26 | expect(fonts.style.subText()).toEqual(expect.arrayContaining([expect.stringContaining('font-weight:normal;')]));
27 | });
28 | });
29 |
30 | describe('Tests for dynamicFontSize method', () => {
31 | it('should return dynamic font stylings along with required media queries', () => {
32 | const font = fonts.size.large;
33 | const desktopDelta = 1;
34 | const tabletDelta = 0.4;
35 | const expectedResult = css`
36 | ${font()}
37 | ${media.greaterThan('tablet')`font-size: ${
38 | tabletDelta + parseInt((font()[0]! as string).replace('font-size:', '').replace('rem;', '').replace(/\s+/g, ''))
39 | }rem;`}
40 | ${media.greaterThan('desktop')`font-size: ${
41 | desktopDelta +
42 | parseInt((font()[0]! as string).replace('font-size:', '').replace('rem;', '').replace(/\s+/g, ''))
43 | }rem;`}
44 | `;
45 | expect(JSON.stringify(dynamicFontSize(font, desktopDelta, tabletDelta))).toEqual(JSON.stringify(expectedResult));
46 | });
47 |
48 | it('should return default dynamic font stylings along with required media queries', () => {
49 | const font = fonts.size.large;
50 |
51 | const expectedResult = css`
52 | ${font()}
53 | ${media.greaterThan('tablet')`font-size: ${
54 | 0 + parseInt((font()[0]! as string).replace('font-size:', '').replace('rem;', '').replace(/\s+/g, ''))
55 | }rem;`}
56 | ${media.greaterThan('desktop')`font-size: ${
57 | 0 + parseInt((font()[0]! as string).replace('font-size:', '').replace('rem;', '').replace(/\s+/g, ''))
58 | }rem;`}
59 | `;
60 | expect(JSON.stringify(dynamicFontSize(font))).toEqual(JSON.stringify(expectedResult));
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/app/translations/en.js:
--------------------------------------------------------------------------------
1 | /*eslint-disable*/module.exports={messages:JSON.parse("{\"spacex_search\":\"spacex_search\",\"launch_search_default\":\"launch_search_default\",\"get_launch_details\":\"get_launch_details\",\"wednesday_solutions\":\"wednesday_solutions\",\"launches_list\":\"launches_list\",\"something_went_wrong\":\"something_went_wrong\",\"fallback\":\"fallback\",\"placeholder_text\":\"placeholder_text\",\"details\":\"details\",\"name_label\":\"name_label\",\"type_label\":\"type_label\",\"ships\":\"ships\",\"rocket\":\"rocket\",\"mission_name\":\"mission_name\",\"launch_date\":\"launch_date\",\"not_found_page_container\":\"not_found_page_container\"}")};
--------------------------------------------------------------------------------
/app/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "spacex_search": "Spacex Launches Search",
3 | "launch_search_default": "Search for a launch by entering it's name in the search box",
4 | "get_launch_details": "Get details of launches by Spacex",
5 | "wednesday_solutions": "Wednesday Solutions",
6 | "launches_list": "List of launches",
7 | "something_went_wrong": "Something went wrong",
8 | "fallback": "No results found for the search term.",
9 | "placeholder_text": "SEARCH BY MISSION NAME",
10 | "details": "Details: {details}",
11 | "name_label": "Name: {name}",
12 | "type_label": "Type: {type}",
13 | "ships": "Ships ",
14 | "rocket": "Rocket ",
15 | "mission_name": "{missionName}",
16 | "launch_date": "{launchDate}",
17 | "not_found_page_container": "Oops, this page doesn't exist!"
18 | }
19 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "skipLibCheck": true,
6 | "esModuleInterop": true,
7 | "allowJs": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "baseUrl": ".",
19 | "paths": {
20 | "@app/*": ["./*"],
21 | "@components/*": ["components/*"],
22 | "@components": ["components"],
23 | "@containers/*": ["containers/*"],
24 | "@utils/*": ["utils/*"],
25 | "@services/*": ["services/*"],
26 | "@themes/*": ["themes/*"],
27 | "@images/*": ["images/*"]
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/utils/apiUtils.ts:
--------------------------------------------------------------------------------
1 | import { ApisauceInstance, create } from 'apisauce';
2 | import snakeCase from 'lodash-es/snakeCase';
3 | import camelCase from 'lodash-es/camelCase';
4 | import { mapKeysDeep } from './index';
5 |
6 | const apiClients: Record = {};
7 |
8 | export const getApiClient = (type = 'spacex') => apiClients[type];
9 |
10 | export const generateApiClient = (type = 'spacex') => {
11 | switch (type) {
12 | case 'spacex':
13 | apiClients[type] = createApiClientWithTransForm(process.env.SPACEX_URL!);
14 | return apiClients[type];
15 | default:
16 | apiClients.default = createApiClientWithTransForm(process.env.SPACEX_URL!);
17 | return apiClients.default;
18 | }
19 | };
20 |
21 | export const createApiClientWithTransForm = (baseURL: string) => {
22 | const api = create({
23 | baseURL,
24 | headers: { 'Content-Type': 'application/json' }
25 | });
26 | api.addResponseTransform((response) => {
27 | const { ok, data } = response;
28 | if (ok && data) {
29 | response.data = mapKeysDeep(data, camelCase);
30 | }
31 | return response;
32 | });
33 |
34 | api.addRequestTransform((request) => {
35 | const { data } = request;
36 | if (data) {
37 | request.data = mapKeysDeep(data, snakeCase);
38 | }
39 | return request;
40 | });
41 | return api;
42 | };
43 |
--------------------------------------------------------------------------------
/app/utils/constants.ts:
--------------------------------------------------------------------------------
1 | export const HEADER_HEIGHT = '7rem';
2 | export const MIN_SIDEBAR_WIDTH = '4.5rem';
3 | export const MOBILE_DRAWER_BREAKPOINT = 450;
4 |
--------------------------------------------------------------------------------
/app/utils/graphqlUtils.ts:
--------------------------------------------------------------------------------
1 | import ApolloClient, { DocumentNode, InMemoryCache } from 'apollo-boost';
2 | import camelCase from 'lodash-es/camelCase';
3 | import { mapKeysDeep } from '.';
4 |
5 | export const client = new ApolloClient({
6 | uri: 'https://spacex-production.up.railway.app',
7 | cache: new InMemoryCache()
8 | });
9 |
10 | export interface GqlQueryReponse {
11 | data?: Data;
12 | error?: any;
13 | ok: boolean;
14 | }
15 |
16 | export const getQueryResponse = (
17 | query: DocumentNode,
18 | variables?: Variables
19 | ): Promise> => {
20 | return client
21 | .query({ query, variables })
22 | .then((res) => {
23 | if (res.errors) {
24 | return { error: res.errors, ok: false };
25 | }
26 | return { data: mapKeysDeep(res.data, camelCase), ok: true };
27 | })
28 | .catch((err) => {
29 | return { error: err, ok: false };
30 | });
31 | };
32 |
--------------------------------------------------------------------------------
/app/utils/history.ts:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from 'history';
2 | const history = createBrowserHistory();
3 | export default history;
4 |
--------------------------------------------------------------------------------
/app/utils/index.ts:
--------------------------------------------------------------------------------
1 | import find from 'lodash-es/find';
2 | import get from 'lodash-es/get';
3 | import { i18n } from '@lingui/core';
4 | import history from './history';
5 | import routeConstants from './routeConstants';
6 |
7 | export const translate = (id: string, values: Record = {}) => i18n._({ id, values });
8 |
9 | export const getCurrentRouteDetails = (location: Partial) => {
10 | if (!get(location, 'pathname')) {
11 | return null;
12 | }
13 | const route = find(
14 | Object.keys(routeConstants),
15 | (key) => routeConstants[key].route === location.pathname || `${routeConstants[key].route}/` === location.pathname
16 | );
17 | if (route) {
18 | return routeConstants[route];
19 | }
20 | return null;
21 | };
22 | export const mapKeysDeep = (obj: T, fn: (key: string) => string): T =>
23 | Array.isArray(obj)
24 | ? obj.map((val) => mapKeysDeep(val, fn))
25 | : typeof obj === 'object'
26 | ? Object.keys(obj).reduce((acc, current) => {
27 | const key = fn(current);
28 | const val = obj[current as keyof T];
29 | acc[key] = val !== null && typeof val === 'object' ? mapKeysDeep(val, fn) : val;
30 | return acc;
31 | }, {} as any)
32 | : obj;
33 |
34 | export const isLocal = () => {
35 | try {
36 | if (process.env.IS_LOCAL) {
37 | const local = JSON.parse(process.env.IS_LOCAL);
38 | return typeof local === 'boolean' && local;
39 | }
40 | } catch {
41 | // continue regardless of error
42 | }
43 | return false;
44 | };
45 |
46 | interface SetQueryParamOptions {
47 | param: string;
48 | value?: string | number;
49 | deleteParam?: boolean;
50 | historyOp?: 'push' | 'replace';
51 | }
52 |
53 | export const setQueryParam = ({ param, value, deleteParam, historyOp = 'push' }: SetQueryParamOptions) => {
54 | const urlParams = new URLSearchParams(history.location.search);
55 | if (deleteParam) {
56 | urlParams.delete(param);
57 | } else {
58 | urlParams.set(param, String(value));
59 | }
60 | if (typeof history[historyOp] === 'function') {
61 | history[historyOp]({ search: urlParams.toString() });
62 | } else {
63 | throw new Error('Invalid history operation');
64 | }
65 | };
66 |
67 | export const prepare = (payload: any) => ({ payload });
68 |
--------------------------------------------------------------------------------
/app/utils/loadable.tsx:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense } from 'react';
2 |
3 | const loadable = (importFunc: () => Promise, { fallback = null } = { fallback: null }) => {
4 | const LazyComponent = lazy(importFunc);
5 |
6 | return (props: any) => (
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default loadable;
14 |
--------------------------------------------------------------------------------
/app/utils/routeConstants.ts:
--------------------------------------------------------------------------------
1 | export type RouteConstant = {
2 | route: string;
3 | exact?: boolean;
4 | isProtected?: boolean;
5 | props?: object;
6 | };
7 |
8 | const routeConstants: Record = {
9 | home: {
10 | route: '/',
11 | exact: true
12 | },
13 | launch: {
14 | route: '/launch/:launchId',
15 | exact: true
16 | }
17 | };
18 |
19 | export type RouteKeys = keyof typeof routeConstants;
20 |
21 | export default routeConstants;
22 |
--------------------------------------------------------------------------------
/app/utils/testUtils.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { I18nProvider } from '@lingui/react';
3 | import { i18n } from '@lingui/core';
4 | import { render } from '@testing-library/react';
5 | import { Provider } from 'react-redux';
6 | import { Route, Router } from 'react-router-dom';
7 | import { ThemeProvider } from 'styled-components';
8 | import configureStore from '@app/configureStore';
9 | import { DEFAULT_LOCALE, translationMessages } from '@app/i18n';
10 | import ConnectedLanguageProvider from '@containers/LanguageProvider';
11 | import history from './history';
12 |
13 | export const renderWithIntl = (children: React.ReactNode) => {
14 | i18n.load(DEFAULT_LOCALE, translationMessages[DEFAULT_LOCALE]);
15 | i18n.activate(DEFAULT_LOCALE);
16 |
17 | return render( {children} );
18 | };
19 |
20 | export const getComponentStyles = (Component: React.FC, props = {}) => {
21 | renderWithIntl(Component(props));
22 | const { styledComponentId } = Component(props)!.type;
23 | const componentRoots = document.getElementsByClassName(styledComponentId);
24 | return window.getComputedStyle(componentRoots[0]);
25 | };
26 |
27 | export const renderProvider = (children: React.ReactNode, { path }: { path?: string } = {}, renderFn = render) => {
28 | const store = configureStore({}).store;
29 | return renderFn(
30 |
31 |
32 |
37 | {path ? {children} : children}
38 |
39 |
40 |
41 | );
42 | };
43 | export const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
44 | export const apiResponseGenerator = (ok: boolean, data: Data, error?: object) => ({
45 | ok,
46 | data,
47 | error
48 | });
49 |
--------------------------------------------------------------------------------
/app/utils/tests/graphqlUtils.test.ts:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-boost';
2 | import { client, getQueryResponse } from '../graphqlUtils';
3 |
4 | describe('graphql utils tests', () => {
5 | const sampleQuery = gql`
6 | query {
7 | launches {
8 | id
9 | }
10 | }
11 | `;
12 |
13 | it('should call a query and return the response', async () => {
14 | const res = await getQueryResponse(sampleQuery);
15 | expect(res.data).toBeUndefined();
16 | expect(res.ok).toEqual(true);
17 | });
18 | it('should return an error when the client sends an error', async () => {
19 | jest.spyOn(client, 'query').mockReturnValueOnce(Promise.reject(new Error()));
20 | const res = await getQueryResponse(sampleQuery);
21 | expect(res.data).toBeUndefined();
22 | expect(res.ok).toEqual(false);
23 | expect(res.error).toEqual(new Error());
24 | });
25 |
26 | it('should return an error when the response has an error', async () => {
27 | jest.spyOn(client, 'query').mockResolvedValue({ errors: 'sample error' } as any);
28 | const res = await getQueryResponse(sampleQuery);
29 | expect(res.data).toBeUndefined();
30 | expect(res.ok).toEqual(false);
31 | expect(res.error).toEqual('sample error');
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/app/utils/tests/history.test.ts:
--------------------------------------------------------------------------------
1 | import history from '../history';
2 |
3 | describe('history tests', () => {
4 | it("should create history object with pathname '/'", () => {
5 | expect(history).toEqual(
6 | expect.objectContaining({
7 | block: expect.any(Function),
8 | createHref: expect.any(Function),
9 | go: expect.any(Function),
10 | goBack: expect.any(Function),
11 | goForward: expect.any(Function),
12 | listen: expect.any(Function),
13 | location: { hash: '', pathname: '/', search: '', state: undefined },
14 | push: expect.any(Function),
15 | replace: expect.any(Function)
16 | })
17 | );
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/app/utils/tests/index.test.ts:
--------------------------------------------------------------------------------
1 | import camelCase from 'lodash-es/camelCase';
2 | import { getCurrentRouteDetails, isLocal, mapKeysDeep, setQueryParam } from '@utils/index';
3 | import routeConstants from '@utils/routeConstants';
4 | import history from '../history';
5 |
6 | describe('Tests for getCurrentRouteDetails method', () => {
7 | const location: Partial = {};
8 | it('should return null if pathname is not available', () => {
9 | expect(getCurrentRouteDetails(location)).toEqual(null);
10 | });
11 |
12 | it('should return the details of the route', () => {
13 | const location = { pathname: '/' };
14 | expect(getCurrentRouteDetails(location)).toEqual(routeConstants.home);
15 | });
16 |
17 | it('should return null of the route if pathname is not in routeConstants', () => {
18 | const location = { pathname: '/launches' };
19 | expect(getCurrentRouteDetails(location)).toEqual(null);
20 | });
21 | });
22 |
23 | describe('Tests for isLocal method', () => {
24 | const OLD_ENV = process.env;
25 |
26 | beforeEach(() => {
27 | jest.resetModules();
28 | process.env = { ...OLD_ENV };
29 | });
30 |
31 | afterAll(() => {
32 | process.env = OLD_ENV;
33 | });
34 |
35 | it('should return true if process.env.IS_LOCAL is true', () => {
36 | process.env.IS_LOCAL = 'true';
37 | expect(isLocal()).toBe(true);
38 | });
39 | it('should return false if when process.env.IS_LOCAL is not present', () => {
40 | expect(isLocal()).toBe(false);
41 | });
42 | it('should return false if process.env.IS_LOCAL has exceptional value', () => {
43 | process.env.IS_LOCAL = 'trusae';
44 | expect(isLocal()).toBe(false);
45 | });
46 | });
47 |
48 | describe('Tests for mapKeysDeep method', () => {
49 | let fn: (str: string) => string;
50 | beforeAll(() => {
51 | fn = (keys: string) => camelCase(keys);
52 | });
53 | it('should return something objet', () => {
54 | const obj = {
55 | locationone: '/route1',
56 | locationtwo: '/route2',
57 | locationthree: { locationone: '/route1', locationtwo: '/route2' }
58 | };
59 | expect(mapKeysDeep(obj, fn)).toEqual(obj);
60 | });
61 |
62 | it('should operate array accordingly', () => {
63 | const arr = [{ locationone: '/route1', locationtwo: '/route2' }];
64 | expect(mapKeysDeep(arr, fn)).toEqual(arr);
65 | });
66 |
67 | it('should return the passed value if its not an array or object', () => {
68 | expect(mapKeysDeep(10, fn)).toEqual(10);
69 | });
70 | });
71 |
72 | describe('setQueryParam tests', () => {
73 | it('should set query param to given value', () => {
74 | history.location.search = '';
75 | setQueryParam({ param: 'key', value: 'value' });
76 | expect(history.location.search).toEqual('?key=value');
77 | });
78 |
79 | it('should delete query param if deleteParan is true', () => {
80 | history.location.search = '?key=value';
81 | setQueryParam({ param: 'key', deleteParam: true });
82 | expect(history.location.search).toEqual('');
83 | });
84 |
85 | it('should throw error if history[historyOp] is not function', () => {
86 | history.location.search = '?key=value';
87 | expect(() => setQueryParam({ param: 'key', deleteParam: true, historyOp: 'unknown' as any })).toThrow(
88 | Error('Invalid history operation')
89 | );
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/app/utils/useMedia.ts:
--------------------------------------------------------------------------------
1 | import useScreenType from 'react-screentype-hook';
2 | import { screenBreakPoints } from '@themes/media';
3 |
4 | export default function useMedia() {
5 | return useScreenType({
6 | mobile: screenBreakPoints.MOBILE,
7 | tablet: screenBreakPoints.TABLET,
8 | desktop: screenBreakPoints.DESKTOP,
9 | largeDesktop: screenBreakPoints.LARGE_DESKTOP
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/app/vendor.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.png' {
2 | const value: any;
3 | export default value;
4 | }
5 |
6 | declare module '*.svg' {
7 | const value: any;
8 | export default value;
9 | }
10 |
11 | declare module 'intl/locale-data/jsonp/en.js';
12 |
13 | // https://github.com/rt2zz/redux-persist-transform-immutable/pull/40/files
14 | declare module 'redux-persist-transform-immutable' {
15 | import { Record } from 'immutable';
16 | import { Transform } from 'redux-persist/es/types';
17 |
18 | interface Config {
19 | records: Record