├── .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 | </StyledHeader> 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', () => <Header id={text('id', 'Header')} />); 15 | -------------------------------------------------------------------------------- /app/components/Header/tests/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`<Header /> should render and match the snapshot 1`] = ` 4 | <body> 5 | <div> 6 | <header 7 | class="ant-layout-header Header__StyledHeader-sc-1oujkb8-0 iCzSdw" 8 | data-testid="header" 9 | > 10 | <a 11 | href="/" 12 | > 13 | <img 14 | alt="logo" 15 | class="Header__Logo-sc-1oujkb8-1 jCVyTI" 16 | src="IMAGE_MOCK" 17 | /> 18 | </a> 19 | <p 20 | class="T__StyledText-znbtqz-0 gcpeYe Header__Title-sc-1oujkb8-2 fbBQMG" 21 | data-testid="t" 22 | > 23 | Wednesday Solutions 24 | </p> 25 | </header> 26 | </div> 27 | </body> 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('<Header />', () => { 12 | it('should render and match the snapshot', () => { 13 | const { baseElement } = renderProvider(<Header />); 14 | expect(baseElement).toMatchSnapshot(); 15 | }); 16 | 17 | it('should contain logo', () => { 18 | const { getAllByAltText } = renderProvider(<Header />); 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<IfProps> = (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[`<LaundDetails> tests should render and match the snapshot 1`] = ` 4 | <body> 5 | <div> 6 | <div 7 | class="ant-card ant-card-bordered LaunchDetails__LaunchDetailsCard-asn7bn-0 kwiAxS" 8 | data-testid="launch-details" 9 | > 10 | <div 11 | class="ant-card-body" 12 | > 13 | <img 14 | class="LaunchDetails__CustomImage-asn7bn-1 jvjRBY" 15 | src="https://farm9.staticflickr.com/8617/16789019815_f99a165dc5_o.jpg" 16 | /> 17 | <div 18 | class="LaunchDetails__DetailsCard-asn7bn-2 cBuJTk" 19 | > 20 | <p 21 | class="T__StyledText-znbtqz-0 jrmeQy LaunchDetails__CustomT-asn7bn-4 dMaFrz" 22 | data-testid="mission-name" 23 | > 24 | CRS-21 25 | </p> 26 | <p 27 | class="T__StyledText-znbtqz-0 TmgHn LaunchDetails__CustomT-asn7bn-4 dMaFrz" 28 | data-testid="details" 29 | > 30 | Details: 31 | SpaceX's 21st ISS resupply mission. 32 | </p> 33 | <p 34 | class="T__StyledText-znbtqz-0 gLJJxM LaunchDetails__LaunchLabelT-asn7bn-6 jvdXQM" 35 | data-testid="t" 36 | > 37 | Rocket 38 | </p> 39 | <div 40 | class="LaunchDetails__RocketBox-asn7bn-3 ezZdiq" 41 | > 42 | <p 43 | class="T__StyledText-znbtqz-0 TmgHn LaunchDetails__CustomT-asn7bn-4 dMaFrz" 44 | data-testid="rocket-name" 45 | > 46 | Name: Falcon 9 47 | </p> 48 | <p 49 | class="T__StyledText-znbtqz-0 TmgHn LaunchDetails__CustomT-asn7bn-4 dMaFrz" 50 | data-testid="rocket-type" 51 | > 52 | Type: FT 53 | </p> 54 | </div> 55 | <p 56 | class="T__StyledText-znbtqz-0 gLJJxM LaunchDetails__LaunchLabelT-asn7bn-6 jvdXQM" 57 | data-testid="t" 58 | > 59 | Ships 60 | </p> 61 | <div 62 | class="LaunchDetails__ShipContainer-asn7bn-7 ksAIqA" 63 | > 64 | <p 65 | class="T__StyledText-znbtqz-0 TmgHn LaunchDetails__CustomT-asn7bn-4 dMaFrz" 66 | data-testid="ship-name" 67 | > 68 | Name: Ship 1 69 | </p> 70 | <p 71 | class="T__StyledText-znbtqz-0 TmgHn LaunchDetails__CustomT-asn7bn-4 dMaFrz" 72 | data-testid="ship-type" 73 | > 74 | Type: Type 1 75 | </p> 76 | </div> 77 | </div> 78 | </div> 79 | </div> 80 | </div> 81 | </body> 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('<LaundDetails> 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(<LaunchDetails {...launchDetails} />); 27 | expect(baseElement).toMatchSnapshot(); 28 | }); 29 | 30 | it('should render the mission name if it is available', () => { 31 | const { getByTestId } = renderProvider(<LaunchDetails {...launchDetails} />); 32 | expect(getByTestId('mission-name')).toBeInTheDocument(); 33 | }); 34 | it('should render the ship name if it is available', () => { 35 | const { getByTestId } = renderProvider(<LaunchDetails {...launchDetails} />); 36 | expect(getByTestId('ship-name')).toBeInTheDocument(); 37 | }); 38 | it('should render the ship type if it is available', () => { 39 | const { getByTestId } = renderProvider(<LaunchDetails {...launchDetails} />); 40 | expect(getByTestId('ship-type')).toBeInTheDocument(); 41 | }); 42 | it('should render the rocket name if it is available', () => { 43 | const { getByTestId } = renderProvider(<LaunchDetails {...launchDetails} />); 44 | expect(getByTestId('rocket-name')).toBeInTheDocument(); 45 | }); 46 | it('should render the rocket type if it is available', () => { 47 | const { getByTestId } = renderProvider(<LaunchDetails {...launchDetails} />); 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(<LaunchDetails {...launchDetails} />); 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 | <LaunchCard data-testid="launch-item" onClick={goToLaunch}> 49 | <If condition={!isEmpty(missionName)}> 50 | <T 51 | data-testid="mission-name" 52 | marginBottom={1.5} 53 | type="subheading" 54 | text={missionName} 55 | id="mission_name" 56 | values={{ missionName }} 57 | /> 58 | </If> 59 | <If condition={!isEmpty(launchDateUtc)}> 60 | <T text={memoizedLaunchDate} id="launch_date" values={{ launchDate: memoizedLaunchDate }} /> 61 | </If> 62 | <If condition={!isEmpty(links)}> 63 | <If condition={!isEmpty(links.wikipedia)}> 64 | <WikiLink 65 | data-testid="wiki-link" 66 | type="link" 67 | rel="noreferrer" 68 | target="_blank" 69 | onClick={(e) => e.stopPropagation()} 70 | href={links.wikipedia} 71 | icon={<GlobalOutlined />} 72 | > 73 | Wikipedia 74 | </WikiLink> 75 | </If> 76 | </If> 77 | </LaunchCard> 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[`<LaunchItem /> should render and match the snapshot 1`] = ` 4 | <body> 5 | <div> 6 | <div 7 | class="ant-card ant-card-bordered LaunchItem__LaunchCard-yxhovu-0 kZTzfL" 8 | data-testid="launch-item" 9 | > 10 | <div 11 | class="ant-card-body" 12 | > 13 | <p 14 | class="T__StyledText-znbtqz-0 ciuNwl" 15 | data-testid="mission-name" 16 | > 17 | Thaicom 6 18 | </p> 19 | <p 20 | class="T__StyledText-znbtqz-0 TmgHn" 21 | data-testid="t" 22 | > 23 | Mon, 6th January 2014, 06:06 PM 24 | </p> 25 | <a 26 | class="ant-btn ant-btn-link LaunchItem__WikiLink-yxhovu-1 ddjxGd" 27 | data-testid="wiki-link" 28 | href="https://en.wikipedia.org/wiki/Thaicom_6" 29 | rel="noreferrer" 30 | target="_blank" 31 | > 32 | <span 33 | aria-label="global" 34 | class="anticon anticon-global" 35 | role="img" 36 | > 37 | <svg 38 | aria-hidden="true" 39 | data-icon="global" 40 | fill="currentColor" 41 | focusable="false" 42 | height="1em" 43 | viewBox="64 64 896 896" 44 | width="1em" 45 | > 46 | <path 47 | d="M854.4 800.9c.2-.3.5-.6.7-.9C920.6 722.1 960 621.7 960 512s-39.4-210.1-104.8-288c-.2-.3-.5-.5-.7-.8-1.1-1.3-2.1-2.5-3.2-3.7-.4-.5-.8-.9-1.2-1.4l-4.1-4.7-.1-.1c-1.5-1.7-3.1-3.4-4.6-5.1l-.1-.1c-3.2-3.4-6.4-6.8-9.7-10.1l-.1-.1-4.8-4.8-.3-.3c-1.5-1.5-3-2.9-4.5-4.3-.5-.5-1-1-1.6-1.5-1-1-2-1.9-3-2.8-.3-.3-.7-.6-1-1C736.4 109.2 629.5 64 512 64s-224.4 45.2-304.3 119.2c-.3.3-.7.6-1 1-1 .9-2 1.9-3 2.9-.5.5-1 1-1.6 1.5-1.5 1.4-3 2.9-4.5 4.3l-.3.3-4.8 4.8-.1.1c-3.3 3.3-6.5 6.7-9.7 10.1l-.1.1c-1.6 1.7-3.1 3.4-4.6 5.1l-.1.1c-1.4 1.5-2.8 3.1-4.1 4.7-.4.5-.8.9-1.2 1.4-1.1 1.2-2.1 2.5-3.2 3.7-.2.3-.5.5-.7.8C103.4 301.9 64 402.3 64 512s39.4 210.1 104.8 288c.2.3.5.6.7.9l3.1 3.7c.4.5.8.9 1.2 1.4l4.1 4.7c0 .1.1.1.1.2 1.5 1.7 3 3.4 4.6 5l.1.1c3.2 3.4 6.4 6.8 9.6 10.1l.1.1c1.6 1.6 3.1 3.2 4.7 4.7l.3.3c3.3 3.3 6.7 6.5 10.1 9.6 80.1 74 187 119.2 304.5 119.2s224.4-45.2 304.3-119.2a300 300 0 0010-9.6l.3-.3c1.6-1.6 3.2-3.1 4.7-4.7l.1-.1c3.3-3.3 6.5-6.7 9.6-10.1l.1-.1c1.5-1.7 3.1-3.3 4.6-5 0-.1.1-.1.1-.2 1.4-1.5 2.8-3.1 4.1-4.7.4-.5.8-.9 1.2-1.4a99 99 0 003.3-3.7zm4.1-142.6c-13.8 32.6-32 62.8-54.2 90.2a444.07 444.07 0 00-81.5-55.9c11.6-46.9 18.8-98.4 20.7-152.6H887c-3 40.9-12.6 80.6-28.5 118.3zM887 484H743.5c-1.9-54.2-9.1-105.7-20.7-152.6 29.3-15.6 56.6-34.4 81.5-55.9A373.86 373.86 0 01887 484zM658.3 165.5c39.7 16.8 75.8 40 107.6 69.2a394.72 394.72 0 01-59.4 41.8c-15.7-45-35.8-84.1-59.2-115.4 3.7 1.4 7.4 2.9 11 4.4zm-90.6 700.6c-9.2 7.2-18.4 12.7-27.7 16.4V697a389.1 389.1 0 01115.7 26.2c-8.3 24.6-17.9 47.3-29 67.8-17.4 32.4-37.8 58.3-59 75.1zm59-633.1c11 20.6 20.7 43.3 29 67.8A389.1 389.1 0 01540 327V141.6c9.2 3.7 18.5 9.1 27.7 16.4 21.2 16.7 41.6 42.6 59 75zM540 640.9V540h147.5c-1.6 44.2-7.1 87.1-16.3 127.8l-.3 1.2A445.02 445.02 0 00540 640.9zm0-156.9V383.1c45.8-2.8 89.8-12.5 130.9-28.1l.3 1.2c9.2 40.7 14.7 83.5 16.3 127.8H540zm-56 56v100.9c-45.8 2.8-89.8 12.5-130.9 28.1l-.3-1.2c-9.2-40.7-14.7-83.5-16.3-127.8H484zm-147.5-56c1.6-44.2 7.1-87.1 16.3-127.8l.3-1.2c41.1 15.6 85 25.3 130.9 28.1V484H336.5zM484 697v185.4c-9.2-3.7-18.5-9.1-27.7-16.4-21.2-16.7-41.7-42.7-59.1-75.1-11-20.6-20.7-43.3-29-67.8 37.2-14.6 75.9-23.3 115.8-26.1zm0-370a389.1 389.1 0 01-115.7-26.2c8.3-24.6 17.9-47.3 29-67.8 17.4-32.4 37.8-58.4 59.1-75.1 9.2-7.2 18.4-12.7 27.7-16.4V327zM365.7 165.5c3.7-1.5 7.3-3 11-4.4-23.4 31.3-43.5 70.4-59.2 115.4-21-12-40.9-26-59.4-41.8 31.8-29.2 67.9-52.4 107.6-69.2zM165.5 365.7c13.8-32.6 32-62.8 54.2-90.2 24.9 21.5 52.2 40.3 81.5 55.9-11.6 46.9-18.8 98.4-20.7 152.6H137c3-40.9 12.6-80.6 28.5-118.3zM137 540h143.5c1.9 54.2 9.1 105.7 20.7 152.6a444.07 444.07 0 00-81.5 55.9A373.86 373.86 0 01137 540zm228.7 318.5c-39.7-16.8-75.8-40-107.6-69.2 18.5-15.8 38.4-29.7 59.4-41.8 15.7 45 35.8 84.1 59.2 115.4-3.7-1.4-7.4-2.9-11-4.4zm292.6 0c-3.7 1.5-7.3 3-11 4.4 23.4-31.3 43.5-70.4 59.2-115.4 21 12 40.9 26 59.4 41.8a373.81 373.81 0 01-107.6 69.2z" 48 | /> 49 | </svg> 50 | </span> 51 | <span> 52 | Wikipedia 53 | </span> 54 | </a> 55 | </div> 56 | </div> 57 | </div> 58 | </body> 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('<LaunchItem />', () => { 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(<LaunchItem {...launch} />); 25 | expect(baseElement).toMatchSnapshot(); 26 | }); 27 | 28 | it('should take us to launch details page if clicked on it', () => { 29 | const { getByTestId } = renderProvider(<LaunchItem {...launch} />); 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(<LaunchItem {...launch} />); 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 | <If 38 | condition={!isEmpty(launches) || loading} 39 | otherwise={ 40 | <CustomErrorCard> 41 | <T data-testid="default-message" id="fallback" /> 42 | </CustomErrorCard> 43 | } 44 | > 45 | <Skeleton loading={loading} active> 46 | <For of={launches} ParentComponent={Container} renderItem={(launch: Launch) => <LaunchItem {...launch} />} /> 47 | </Skeleton> 48 | </If> 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', () => <LaunchList launchData={launchData} loading={loading} />); 29 | -------------------------------------------------------------------------------- /app/components/LaunchList/tests/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`<LaunchList /> should render and match the snapshot 1`] = ` 4 | <body> 5 | <div> 6 | <div 7 | class="LaunchList__Container-sc-1rqj0ky-1 eTexWw" 8 | data-testid="for" 9 | orientation="row" 10 | > 11 | <div 12 | class="ant-card ant-card-bordered LaunchItem__LaunchCard-yxhovu-0 kZTzfL" 13 | data-testid="launch-item" 14 | > 15 | <div 16 | class="ant-card-body" 17 | > 18 | <p 19 | class="T__StyledText-znbtqz-0 ciuNwl" 20 | data-testid="mission-name" 21 | > 22 | Thaicom 6 23 | </p> 24 | <p 25 | class="T__StyledText-znbtqz-0 TmgHn" 26 | data-testid="t" 27 | > 28 | Mon, 6th January 2014, 06:06 PM 29 | </p> 30 | <a 31 | class="ant-btn ant-btn-link LaunchItem__WikiLink-yxhovu-1 ddjxGd" 32 | data-testid="wiki-link" 33 | href="https://en.wikipedia.org/wiki/Thaicom_6" 34 | rel="noreferrer" 35 | target="_blank" 36 | > 37 | <span 38 | aria-label="global" 39 | class="anticon anticon-global" 40 | role="img" 41 | > 42 | <svg 43 | aria-hidden="true" 44 | data-icon="global" 45 | fill="currentColor" 46 | focusable="false" 47 | height="1em" 48 | viewBox="64 64 896 896" 49 | width="1em" 50 | > 51 | <path 52 | d="M854.4 800.9c.2-.3.5-.6.7-.9C920.6 722.1 960 621.7 960 512s-39.4-210.1-104.8-288c-.2-.3-.5-.5-.7-.8-1.1-1.3-2.1-2.5-3.2-3.7-.4-.5-.8-.9-1.2-1.4l-4.1-4.7-.1-.1c-1.5-1.7-3.1-3.4-4.6-5.1l-.1-.1c-3.2-3.4-6.4-6.8-9.7-10.1l-.1-.1-4.8-4.8-.3-.3c-1.5-1.5-3-2.9-4.5-4.3-.5-.5-1-1-1.6-1.5-1-1-2-1.9-3-2.8-.3-.3-.7-.6-1-1C736.4 109.2 629.5 64 512 64s-224.4 45.2-304.3 119.2c-.3.3-.7.6-1 1-1 .9-2 1.9-3 2.9-.5.5-1 1-1.6 1.5-1.5 1.4-3 2.9-4.5 4.3l-.3.3-4.8 4.8-.1.1c-3.3 3.3-6.5 6.7-9.7 10.1l-.1.1c-1.6 1.7-3.1 3.4-4.6 5.1l-.1.1c-1.4 1.5-2.8 3.1-4.1 4.7-.4.5-.8.9-1.2 1.4-1.1 1.2-2.1 2.5-3.2 3.7-.2.3-.5.5-.7.8C103.4 301.9 64 402.3 64 512s39.4 210.1 104.8 288c.2.3.5.6.7.9l3.1 3.7c.4.5.8.9 1.2 1.4l4.1 4.7c0 .1.1.1.1.2 1.5 1.7 3 3.4 4.6 5l.1.1c3.2 3.4 6.4 6.8 9.6 10.1l.1.1c1.6 1.6 3.1 3.2 4.7 4.7l.3.3c3.3 3.3 6.7 6.5 10.1 9.6 80.1 74 187 119.2 304.5 119.2s224.4-45.2 304.3-119.2a300 300 0 0010-9.6l.3-.3c1.6-1.6 3.2-3.1 4.7-4.7l.1-.1c3.3-3.3 6.5-6.7 9.6-10.1l.1-.1c1.5-1.7 3.1-3.3 4.6-5 0-.1.1-.1.1-.2 1.4-1.5 2.8-3.1 4.1-4.7.4-.5.8-.9 1.2-1.4a99 99 0 003.3-3.7zm4.1-142.6c-13.8 32.6-32 62.8-54.2 90.2a444.07 444.07 0 00-81.5-55.9c11.6-46.9 18.8-98.4 20.7-152.6H887c-3 40.9-12.6 80.6-28.5 118.3zM887 484H743.5c-1.9-54.2-9.1-105.7-20.7-152.6 29.3-15.6 56.6-34.4 81.5-55.9A373.86 373.86 0 01887 484zM658.3 165.5c39.7 16.8 75.8 40 107.6 69.2a394.72 394.72 0 01-59.4 41.8c-15.7-45-35.8-84.1-59.2-115.4 3.7 1.4 7.4 2.9 11 4.4zm-90.6 700.6c-9.2 7.2-18.4 12.7-27.7 16.4V697a389.1 389.1 0 01115.7 26.2c-8.3 24.6-17.9 47.3-29 67.8-17.4 32.4-37.8 58.3-59 75.1zm59-633.1c11 20.6 20.7 43.3 29 67.8A389.1 389.1 0 01540 327V141.6c9.2 3.7 18.5 9.1 27.7 16.4 21.2 16.7 41.6 42.6 59 75zM540 640.9V540h147.5c-1.6 44.2-7.1 87.1-16.3 127.8l-.3 1.2A445.02 445.02 0 00540 640.9zm0-156.9V383.1c45.8-2.8 89.8-12.5 130.9-28.1l.3 1.2c9.2 40.7 14.7 83.5 16.3 127.8H540zm-56 56v100.9c-45.8 2.8-89.8 12.5-130.9 28.1l-.3-1.2c-9.2-40.7-14.7-83.5-16.3-127.8H484zm-147.5-56c1.6-44.2 7.1-87.1 16.3-127.8l.3-1.2c41.1 15.6 85 25.3 130.9 28.1V484H336.5zM484 697v185.4c-9.2-3.7-18.5-9.1-27.7-16.4-21.2-16.7-41.7-42.7-59.1-75.1-11-20.6-20.7-43.3-29-67.8 37.2-14.6 75.9-23.3 115.8-26.1zm0-370a389.1 389.1 0 01-115.7-26.2c8.3-24.6 17.9-47.3 29-67.8 17.4-32.4 37.8-58.4 59.1-75.1 9.2-7.2 18.4-12.7 27.7-16.4V327zM365.7 165.5c3.7-1.5 7.3-3 11-4.4-23.4 31.3-43.5 70.4-59.2 115.4-21-12-40.9-26-59.4-41.8 31.8-29.2 67.9-52.4 107.6-69.2zM165.5 365.7c13.8-32.6 32-62.8 54.2-90.2 24.9 21.5 52.2 40.3 81.5 55.9-11.6 46.9-18.8 98.4-20.7 152.6H137c3-40.9 12.6-80.6 28.5-118.3zM137 540h143.5c1.9 54.2 9.1 105.7 20.7 152.6a444.07 444.07 0 00-81.5 55.9A373.86 373.86 0 01137 540zm228.7 318.5c-39.7-16.8-75.8-40-107.6-69.2 18.5-15.8 38.4-29.7 59.4-41.8 15.7 45 35.8 84.1 59.2 115.4-3.7-1.4-7.4-2.9-11-4.4zm292.6 0c-3.7 1.5-7.3 3-11 4.4 23.4-31.3 43.5-70.4 59.2-115.4 21 12 40.9 26 59.4 41.8a373.81 373.81 0 01-107.6 69.2z" 53 | /> 54 | </svg> 55 | </span> 56 | <span> 57 | Wikipedia 58 | </span> 59 | </a> 60 | </div> 61 | </div> 62 | </div> 63 | </div> 64 | </body> 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('<LaunchList />', () => { 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(<LaunchList loading={loading} launchData={launchData} />); 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(<LaunchList loading={loading} launchData={{}} />); 35 | expect(getByText(message)).toBeInTheDocument(); 36 | }); 37 | it('should render the list for the launches when data is available', () => { 38 | const { getByText } = renderProvider(<LaunchList loading={loading} launchData={launchData} />); 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<RouteComponentProps>; 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 <Component {...renderProps} />; 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 <Component {...renderProps} />; 45 | } 46 | } 47 | return <Redirect to={to} />; 48 | } 49 | return <Route {...rest} render={handleRedirection} />; 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[`<ProtectedRoute /> tests should render and match the snapshot 1`] = ` 4 | <body> 5 | <div> 6 | <h1> 7 | Hello World 8 | </h1> 9 | </div> 10 | </body> 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 = () => <h1>{RENDER_TEXT}</h1>; 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('<ProtectedRoute /> tests', () => { 25 | it('should render and match the snapshot', () => { 26 | const { baseElement } = renderProvider( 27 | <ProtectedRoute isLoggedIn={true} render={HomeContainer} exact={true} path="/" /> 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 | <ProtectedRoute isLoggedIn={true} render={HomeContainer} exact={true} path="/" /> 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 | <ProtectedRoute isLoggedIn={false} render={HomeContainer} exact={true} path="/" handleLogout={logoutSpy} /> 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 | <ProtectedRoute isLoggedIn={false} render={HomeContainer} exact={true} path="/" /> 48 | ); 49 | expect(queryByText(RENDER_TEXT)).toBeNull(); 50 | }); 51 | 52 | it('should render component , not logged in, unprotected route', () => { 53 | const { queryByText } = renderProvider( 54 | <ProtectedRoute isLoggedIn={false} render={HomeContainer} exact={true} path="/login" /> 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 | <ProtectedRoute isLoggedIn={true} render={HomeContainer} exact={true} path="/login" /> 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(<ProtectedRoute isLoggedIn={false} render={HomeContainer} exact={true} path="/" />); 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<RouteComponentProps> { 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: <CloseOutlined style={{ color: colors.secondary, fontSize: '1.9rem' }} />, 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 ? <SidebarDrawer {...props} /> : <SideBarStatic data-testid="sidebar" {...(props as any)} />; 84 | 85 | return ( 86 | <SidebarWrapper> 87 | <If condition={isMobile}> 88 | <MenuButton 89 | data-testid="menu-icon" 90 | type="primary" 91 | size="large" 92 | aria-label="toggle sidebar" 93 | onClick={toggleSidebar} 94 | icon={<MenuImg src={menuIcon} alt="menu icon" />} 95 | /> 96 | </If> 97 | <SidebarComponent {...sidebarProps}> 98 | <Link onClick={toggleSidebar} data-testid="rocket-home-link" aria-label="home link" to="/"> 99 | <RocketLogo src={icon} alt="rocket-icon" /> 100 | </Link> 101 | </SidebarComponent> 102 | </SidebarWrapper> 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('<Sidebar /> tests', () => { 10 | it('should contains menu icon in mobile screen', () => { 11 | (useScreenType as jest.Mock).mockImplementation(() => ({ isMobile: true })); 12 | const { getByTestId } = renderProvider(<Sidebar />); 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(<Sidebar />); 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(<Sidebar />); 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<StyledContainerProps>` 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[`<StyledContainer /> tests should render and match the snapshot 1`] = ` 4 | <body> 5 | <div> 6 | 7 | <div 8 | class="StyledContainer-sc-1xw91me-0 jzYaeF" 9 | /> 10 | 11 | </div> 12 | </body> 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('<StyledContainer /> tests', () => { 6 | it('should render and match the snapshot', () => { 7 | const { baseElement } = renderWithIntl(<StyledContainer />); 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<StyledTextProps>` 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<string, React.ReactNode>; 38 | } 39 | 40 | export const T = (props: TProps) => { 41 | const { type = 'standard', text, id, marginBottom, values = {}, ...otherProps } = props; 42 | return ( 43 | <StyledText data-testid="t" font={getFontStyle(type)} marginBottom={marginBottom} {...otherProps}> 44 | <If condition={!!id} otherwise={text}> 45 | <Trans id={id} values={values} /> 46 | </If> 47 | </StyledText> 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', () => <T id={text('id', 'T')} />); 16 | -------------------------------------------------------------------------------- /app/components/T/tests/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`<T /> component tests should render and match the snapshot 1`] = ` 4 | <body> 5 | <div> 6 | 7 | <p 8 | class="T__StyledText-znbtqz-0 TmgHn" 9 | data-testid="t" 10 | /> 11 | 12 | </div> 13 | </body> 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('<T /> component tests', () => { 12 | it('should render and match the snapshot', () => { 13 | const { baseElement } = renderWithIntl(<T />); 14 | expect(baseElement).toMatchSnapshot(); 15 | }); 16 | 17 | it('should contain 1 T component', () => { 18 | const { getAllByTestId } = renderWithIntl(<T />); 19 | expect(getAllByTestId('t').length).toBe(1); 20 | }); 21 | 22 | it('should contain render the text according to the id', () => { 23 | const { getAllByText } = renderWithIntl(<T id="launches_list" />); 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<typeof persistedReducer>; 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 | <ThemeProvider theme={theme}> 40 | <Header /> 41 | <CustomLayout> 42 | <Sidebar /> 43 | <Layout.Content style={{ background: colors.secondaryText }}> 44 | <For 45 | ParentComponent={(props) => <Switch {...props} />} 46 | of={map(Object.keys(routeConfig))} 47 | renderItem={(routeKey, index) => { 48 | const Component = routeConfig[routeKey].component; 49 | return ( 50 | <Route 51 | exact={routeConfig[routeKey].exact} 52 | key={index} 53 | path={routeConfig[routeKey].route} 54 | render={(props) => { 55 | const updatedProps = { 56 | ...props, 57 | ...routeConfig[routeKey].props 58 | }; 59 | return <Component {...updatedProps} />; 60 | }} 61 | /> 62 | ); 63 | }} 64 | /> 65 | <GlobalStyle /> 66 | </Layout.Content> 67 | </CustomLayout> 68 | </ThemeProvider> 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[`<App /> container tests should render and match the snapshot 1`] = ` 4 | <div> 5 | <header 6 | class="ant-layout-header Header__StyledHeader-sc-1oujkb8-0 jwifCQ" 7 | data-testid="header" 8 | > 9 | <a 10 | href="/" 11 | > 12 | <img 13 | alt="logo" 14 | class="Header__Logo-sc-1oujkb8-1 jCVyTI" 15 | src="IMAGE_MOCK" 16 | /> 17 | </a> 18 | <p 19 | class="T__StyledText-znbtqz-0 gcpeYe Header__Title-sc-1oujkb8-2 fbBQMG" 20 | data-testid="t" 21 | > 22 | Wednesday Solutions 23 | </p> 24 | </header> 25 | <section 26 | class="ant-layout App__CustomLayout-sc-1cjfzn6-0 dcysLj" 27 | > 28 | <div 29 | class="Siderbar__SidebarWrapper-sc-1qq6lqc-0 jnbRYB" 30 | > 31 | <div 32 | class="Siderbar__SideBarStatic-sc-1qq6lqc-2 gfworK" 33 | data-testid="sidebar" 34 | > 35 | <a 36 | aria-label="home link" 37 | data-testid="rocket-home-link" 38 | href="/" 39 | > 40 | <img 41 | alt="rocket-icon" 42 | class="Siderbar__RocketLogo-sc-1qq6lqc-3 pAjdu" 43 | src="IMAGE_MOCK" 44 | /> 45 | </a> 46 | </div> 47 | </div> 48 | <main 49 | class="ant-layout-content" 50 | style="background: rgb(255, 255, 255);" 51 | /> 52 | </section> 53 | </div> 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('<App /> container tests', () => { 8 | it('should render and match the snapshot', async () => { 9 | const { container } = renderProvider( 10 | <BrowserRouter> 11 | <App /> 12 | </BrowserRouter> 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<any, any, LaunchesResponse> { 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[`<HomeContainer /> tests should render and match the snapshot 1`] = ` 4 | <body> 5 | <div> 6 | <div 7 | class="HomeContainer__Container-sc-1ly6omb-0 kbMMyW" 8 | > 9 | <div 10 | class="HomeContainer__CustomHeader-sc-1ly6omb-1 KzIrG" 11 | > 12 | <span 13 | class="ant-input-affix-wrapper ant-input-affix-wrapper-focused HomeContainer__CustomSearch-sc-1ly6omb-2 eyZudg" 14 | > 15 | <span 16 | class="ant-input-prefix" 17 | > 18 | <span 19 | aria-label="search" 20 | class="anticon anticon-search" 21 | role="img" 22 | style="font-size: 22px; color: black;" 23 | > 24 | <svg 25 | aria-hidden="true" 26 | data-icon="search" 27 | fill="currentColor" 28 | focusable="false" 29 | height="1em" 30 | viewBox="64 64 896 896" 31 | width="1em" 32 | > 33 | <path 34 | d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z" 35 | /> 36 | </svg> 37 | </span> 38 | </span> 39 | <input 40 | class="ant-input" 41 | data-testid="search-bar" 42 | placeholder="SEARCH BY MISSION NAME" 43 | type="text" 44 | value="" 45 | /> 46 | </span> 47 | <div 48 | class="HomeContainer__ButtonBox-sc-1ly6omb-3 gSuRgZ" 49 | > 50 | <div 51 | class="ant-select HomeContainer__SortSelect-sc-1ly6omb-4 dTbpQd ant-select-single ant-select-show-arrow" 52 | data-testid="sort-select" 53 | > 54 | <div 55 | class="ant-select-selector" 56 | > 57 | <span 58 | class="ant-select-selection-search" 59 | > 60 | <input 61 | aria-activedescendant="sort-select_list_0" 62 | aria-autocomplete="list" 63 | aria-controls="sort-select_list" 64 | aria-haspopup="listbox" 65 | aria-owns="sort-select_list" 66 | autocomplete="off" 67 | class="ant-select-selection-search-input" 68 | id="sort-select" 69 | readonly="" 70 | role="combobox" 71 | style="opacity: 0;" 72 | type="search" 73 | unselectable="on" 74 | value="" 75 | /> 76 | </span> 77 | <span 78 | class="ant-select-selection-item" 79 | title="SORT BY DATE" 80 | > 81 | SORT BY DATE 82 | </span> 83 | </div> 84 | <span 85 | aria-hidden="true" 86 | class="ant-select-arrow" 87 | style="user-select: none;" 88 | unselectable="on" 89 | > 90 | <img 91 | alt="chevron-up-down" 92 | src="IMAGE_MOCK" 93 | /> 94 | </span> 95 | </div> 96 | <button 97 | class="ant-btn" 98 | data-testid="clear-sort" 99 | disabled="" 100 | type="button" 101 | > 102 | <span> 103 | CLEAR SORT 104 | </span> 105 | </button> 106 | </div> 107 | </div> 108 | <div 109 | class="ant-skeleton ant-skeleton-active" 110 | > 111 | <div 112 | class="ant-skeleton-content" 113 | > 114 | <h3 115 | class="ant-skeleton-title" 116 | style="width: 38%;" 117 | /> 118 | <ul 119 | class="ant-skeleton-paragraph" 120 | > 121 | <li /> 122 | <li /> 123 | <li 124 | style="width: 61%;" 125 | /> 126 | </ul> 127 | </div> 128 | </div> 129 | <div 130 | class="HomeContainer__CustomFooter-sc-1ly6omb-5 kYaCDi" 131 | > 132 | <button 133 | class="ant-btn ant-btn-primary" 134 | data-testid="prev-btn" 135 | disabled="" 136 | type="button" 137 | > 138 | <span> 139 | PREV 140 | </span> 141 | </button> 142 | <button 143 | class="ant-btn ant-btn-primary" 144 | data-testid="next-btn" 145 | disabled="" 146 | type="button" 147 | > 148 | <span> 149 | NEXT 150 | </span> 151 | </button> 152 | </div> 153 | </div> 154 | </div> 155 | </body> 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<Launch>[] }; 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<string>; 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<string, object>; 20 | } 21 | 22 | export function LanguageProvider({ locale, messages, children }: PropsWithChildren<LanguageProviderProps>) { 23 | const localizedMessages = messages[locale]; 24 | 25 | i18n.load(locale, localizedMessages); 26 | i18n.activate(locale); 27 | 28 | return <I18nProvider i18n={i18n}>{React.Children.only(children)}</I18nProvider>; 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<RootState>) => 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('<LanguageProvider /> tests', () => { 11 | it('should render its children', () => { 12 | const children = <h1>Test</h1>; 13 | const { container } = render( 14 | <LanguageProvider messages={translationMessages} locale={DEFAULT_LOCALE}> 15 | {children} 16 | </LanguageProvider> 17 | ); 18 | expect(container.firstChild).not.toBeNull(); 19 | }); 20 | }); 21 | 22 | describe('<ConnectedLanguageProvider /> tests', () => { 23 | let store: Store<RootState>; 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 | <Provider store={store}> 33 | <ConnectedLanguageProvider locale={DEFAULT_LOCALE} messages={translationMessages}> 34 | <Trans id="ships" values={{ ships: message }} /> 35 | </ConnectedLanguageProvider> 36 | </Provider> 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<RootState>; 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 | <div> 28 | <Helmet> 29 | <title>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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/images/ArrowUp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/images/ArrowUpDown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 2 | 3 | 4 | 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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 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 |
28 |
29 |
30 |
31 |
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; 20 | } 21 | 22 | export default function (config?: Config): Transform; 23 | } 24 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | browsers: ['> 0.25%, not dead'] 8 | }, 9 | modules: false, 10 | corejs: '3.6.5', 11 | useBuiltIns: 'entry' 12 | } 13 | ], 14 | '@babel/preset-react', 15 | '@babel/preset-typescript' 16 | ], 17 | plugins: [ 18 | 'macros', 19 | '@babel/plugin-proposal-optional-chaining', 20 | '@babel/plugin-syntax-optional-chaining', 21 | 'styled-components', 22 | '@babel/plugin-proposal-class-properties', 23 | '@babel/plugin-syntax-dynamic-import' 24 | ], 25 | env: { 26 | production: { 27 | only: ['app'], 28 | plugins: [ 29 | 'transform-react-remove-prop-types', 30 | '@babel/plugin-transform-react-inline-elements', 31 | '@babel/plugin-transform-react-constant-elements', 32 | ['import', { libraryName: 'antd', libraryDirectory: 'es', style: true }, 'antd'], 33 | [ 34 | 'import', 35 | { 36 | libraryName: '@ant-design/icons', 37 | libraryDirectory: 'es/icons', 38 | camel2DashComponentName: false 39 | }, 40 | '@ant-design/icons' 41 | ] 42 | ] 43 | }, 44 | dev: { 45 | plugins: [['import', { libraryName: 'antd', style: true }]] 46 | }, 47 | development: { 48 | plugins: [ 49 | ['import', { libraryName: 'antd', style: true }], 50 | [ 51 | 'import', 52 | { 53 | libraryName: '@ant-design/icons', 54 | libraryDirectory: 'es/icons', 55 | camel2DashComponentName: false 56 | }, 57 | '@ant-design/icons' 58 | ] 59 | ] 60 | }, 61 | test: { 62 | plugins: [ 63 | '@babel/plugin-transform-modules-commonjs', 64 | 'dynamic-import-node', 65 | ['import', { libraryName: 'antd', style: true }] 66 | ] 67 | } 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /badges/badge-branches.svg: -------------------------------------------------------------------------------- 1 | Coverage:branches: 100%Coverage:branches100% -------------------------------------------------------------------------------- /badges/badge-functions.svg: -------------------------------------------------------------------------------- 1 | Coverage:functions: 100%Coverage:functions100% -------------------------------------------------------------------------------- /badges/badge-lines.svg: -------------------------------------------------------------------------------- 1 | Coverage:lines: 100%Coverage:lines100% -------------------------------------------------------------------------------- /badges/badge-statements.svg: -------------------------------------------------------------------------------- 1 | Coverage:statements: 100%Coverage:statements100% -------------------------------------------------------------------------------- /internals/mocks/cssModule.js: -------------------------------------------------------------------------------- 1 | module.exports = 'CSS_MODULE'; 2 | -------------------------------------------------------------------------------- /internals/mocks/image.js: -------------------------------------------------------------------------------- 1 | module.exports = 'IMAGE_MOCK'; 2 | -------------------------------------------------------------------------------- /internals/scripts/analyze.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const shelljs = require('shelljs'); 4 | const chalk = require('chalk'); 5 | const animateProgress = require('./helpers/progress'); 6 | const addCheckMark = require('./helpers/checkmark'); 7 | 8 | const progress = animateProgress('Generating stats'); 9 | 10 | // Generate stats.json file with webpack 11 | shelljs.exec( 12 | 'webpack --config internals/webpack/webpack.config.prod.babel.js --profile --json > stats.json', 13 | addCheckMark.bind(null, callback), // Output a checkmark on completion 14 | ); 15 | 16 | // Called after webpack has finished generating the stats.json file 17 | function callback() { 18 | clearInterval(progress); 19 | process.stdout.write( 20 | '\n\nOpen ' + 21 | chalk.magenta('http://webpack.github.io/analyse/') + 22 | ' in your browser and upload the stats.json file!' + 23 | chalk.blue( 24 | '\n(Tip: ' + chalk.italic('CMD + double-click') + ' the link!)\n\n', 25 | ), 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /internals/scripts/clean.js: -------------------------------------------------------------------------------- 1 | const shell = require('shelljs'); 2 | const addCheckMark = require('./helpers/checkmark.js'); 3 | 4 | if (!shell.which('git')) { 5 | shell.echo('Sorry, this script requires git'); 6 | shell.exit(1); 7 | } 8 | 9 | if (!shell.test('-e', 'internals/templates')) { 10 | shell.echo('The example is deleted already.'); 11 | shell.exit(1); 12 | } 13 | 14 | process.stdout.write('Cleanup started...'); 15 | 16 | // Reuse existing LanguageProvider and i18n tests 17 | shell.mv( 18 | 'app/containers/LanguageProvider/tests', 19 | 'internals/templates/containers/LanguageProvider', 20 | ); 21 | shell.cp('app/tests/i18n.test.js', 'internals/templates/tests/i18n.test.js'); 22 | 23 | // Cleanup components/ 24 | shell.rm('-rf', 'app/components/*'); 25 | 26 | // Handle containers/ 27 | shell.rm('-rf', 'app/containers'); 28 | shell.mv('internals/templates/containers', 'app'); 29 | 30 | // Handle tests/ 31 | shell.mv('internals/templates/tests', 'app'); 32 | 33 | // Handle translations/ 34 | shell.rm('-rf', 'app/translations'); 35 | shell.mv('internals/templates/translations', 'app'); 36 | 37 | // Handle utils/ 38 | shell.rm('-rf', 'app/utils'); 39 | shell.mv('internals/templates/utils', 'app'); 40 | 41 | // Replace the files in the root app/ folder 42 | shell.cp('internals/templates/app.js', 'app/app.js'); 43 | shell.cp('internals/templates/global-styles.js', 'app/global-styles.js'); 44 | shell.cp('internals/templates/i18n.js', 'app/i18n.js'); 45 | shell.cp('internals/templates/index.html', 'app/index.html'); 46 | shell.cp('internals/templates/reducers.js', 'app/reducers.js'); 47 | shell.cp('internals/templates/configureStore.js', 'app/configureStore.js'); 48 | 49 | // Remove the templates folder 50 | shell.rm('-rf', 'internals/templates'); 51 | 52 | addCheckMark(); 53 | 54 | // Commit the changes 55 | if ( 56 | shell.exec('git add . --all && git commit -qm "Remove default example"') 57 | .code !== 0 58 | ) { 59 | shell.echo('\nError: Git commit failed'); 60 | shell.exit(1); 61 | } 62 | 63 | shell.echo('\nCleanup done. Happy Coding!!!'); 64 | -------------------------------------------------------------------------------- /internals/scripts/helpers/checkmark.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | /** 4 | * Adds mark check symbol 5 | */ 6 | function addCheckMark(callback) { 7 | process.stdout.write(chalk.green(' ✓')); 8 | if (callback) callback(); 9 | } 10 | 11 | module.exports = addCheckMark; 12 | -------------------------------------------------------------------------------- /internals/scripts/helpers/get-npm-config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | module.exports = JSON.parse(fs.readFileSync('package.json', 'utf8')); 4 | -------------------------------------------------------------------------------- /internals/scripts/helpers/progress.js: -------------------------------------------------------------------------------- 1 | const readline = require('readline'); 2 | 3 | /** 4 | * Adds an animated progress indicator 5 | * 6 | * @param {string} message The message to write next to the indicator 7 | * @param {number} [amountOfDots=3] The amount of dots you want to animate 8 | */ 9 | function animateProgress(message, amountOfDots = 3) { 10 | let i = 0; 11 | return setInterval(() => { 12 | readline.cursorTo(process.stdout, 0); 13 | i = (i + 1) % (amountOfDots + 1); 14 | const dots = new Array(i + 1).join('.'); 15 | process.stdout.write(message + dots); 16 | }, 500); 17 | } 18 | 19 | module.exports = animateProgress; 20 | -------------------------------------------------------------------------------- /internals/scripts/helpers/xmark.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | /** 4 | * Adds mark cross symbol 5 | */ 6 | function addXMark(callback) { 7 | process.stdout.write(chalk.red(' ✘')); 8 | if (callback) callback(); 9 | } 10 | 11 | module.exports = addXMark; 12 | -------------------------------------------------------------------------------- /internals/scripts/npmcheckversion.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('child_process'); 2 | exec('npm -v', (err, stdout) => { 3 | if (err) throw err; 4 | if (parseFloat(stdout) < 5) { 5 | // NOTE: This can happen if you have a dependency which lists an old version of npm in its own dependencies. 6 | throw new Error(`[ERROR] You need npm version @>=5 but you have ${stdout}`); 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /internals/scripts/tsc-lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | TMP=app/.tsconfig-lint.json 4 | cat >$TMP <> $TMP 11 | done 12 | cat >>$TMP <> $TMP 15 | cat >>$TMP < !/(\.map$)|(^(main\.|favicon\.))/.test(assetFilename) 132 | } 133 | }); 134 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectCoverageFrom": [ 3 | "app/**/*.{js,jsx,ts,tsx}", 4 | "!app/**/*.test.{ts,tsx}", 5 | "!app/*/RbGenerated*/*.{ts,tsx}", 6 | "!app/app.tsx", 7 | "!app/components/ScrollToTop/*.tsx", 8 | "!app/components/ErrorBoundary/*.tsx", 9 | "!app/global-styles.{ts}", 10 | "!app/*/*/loadable.{js,ts,tsx}", 11 | "!**/loadable.tsx", 12 | "!**/apiUtils.ts", 13 | "!**/testUtils.tsx", 14 | "!**/stories/**", 15 | "!**/themes/index.ts", 16 | "!app/vendor.d.ts" 17 | ], 18 | "testEnvironment": "jsdom", 19 | "reporters": [ 20 | "default", 21 | [ 22 | "jest-sonar", 23 | { 24 | "outputDirectory": "reports", 25 | "outputName": "test-report.xml", 26 | "relativeRootDir": "./", 27 | "reportedFilePath": "relative" 28 | } 29 | ] 30 | ], 31 | "coverageThreshold": { 32 | "global": { 33 | "statements": 90, 34 | "branches": 90, 35 | "functions": 90, 36 | "lines": 90 37 | } 38 | }, 39 | "coverageReporters": ["json-summary", "text", "lcov"], 40 | "moduleDirectories": ["node_modules", "app"], 41 | "moduleNameMapper": { 42 | "@app(.*)$": "/app/$1", 43 | "^lodash-es(.*)": "lodash$1", 44 | "@(containers|components|services|utils|themes)(.*)$": "/app/$1/$2", 45 | ".*\\.(css|less|styl|scss|sass)$": "/internals/mocks/cssModule.js", 46 | ".*\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/internals/mocks/image.js" 47 | }, 48 | "transformIgnorePatterns": ["/node_modules/(?!lodash-es/*)"], 49 | "setupFilesAfterEnv": ["/jest.setup.js", "/internals/testing/test-bundler.js"], 50 | "setupFiles": ["raf/polyfill"], 51 | "testRegex": "tests/.*\\.test\\.[jt]sx?$", 52 | "snapshotSerializers": [] 53 | } 54 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | jest.mock('react-router-dom', () => { 4 | const originalModule = jest.requireActual('react-router-dom'); 5 | return { 6 | __esModule: true, 7 | ...originalModule, 8 | useLocation: jest.fn().mockReturnValue({ 9 | pathname: '/', 10 | search: '', 11 | hash: '', 12 | state: null, 13 | key: '5nvxpbdafa' 14 | }), 15 | useHistory: jest.fn().mockReturnValue({ 16 | length: 2, 17 | action: 'POP', 18 | push: jest.fn(), 19 | location: { 20 | pathname: '/', 21 | search: '', 22 | hash: '' 23 | } 24 | }) 25 | }; 26 | }); 27 | 28 | jest.doMock('apollo-boost', () => ({ 29 | __esModule: true, 30 | default: () => ({ query: (query) => Promise.resolve(query) }), 31 | gql: () => ({}), 32 | InMemoryCache: () => ({}) 33 | })); 34 | Object.defineProperty(window, 'matchMedia', { 35 | value: jest.fn(() => { 36 | return { 37 | matches: true, 38 | addListener: jest.fn(), 39 | removeListener: jest.fn() 40 | }; 41 | }) 42 | }); 43 | -------------------------------------------------------------------------------- /lingui.config.js: -------------------------------------------------------------------------------- 1 | import { formatter } from '@lingui/format-json'; 2 | 3 | module.exports = { 4 | fallbackLocales: { 5 | default: 'en' 6 | }, 7 | sourceLocale: 'en', 8 | locales: ['en'], 9 | catalogs: [ 10 | { 11 | path: 'app/translations/{locale}', 12 | include: ['app/**/!(*.test).js'] 13 | } 14 | ], 15 | format: formatter({ style: 'lingui' }) 16 | }; 17 | -------------------------------------------------------------------------------- /react-graphql-template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wednesday-solutions/react-graphql-ts-template/211dd8904aaf0a9019a45aea7131b395a4376764/react-graphql-template.png -------------------------------------------------------------------------------- /server/argv.js: -------------------------------------------------------------------------------- 1 | module.exports = require('minimist')(process.argv.slice(2)) 2 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | /* eslint consistent-return:0 import/order:0 */ 2 | 3 | const express = require('express') 4 | const logger = require('./logger') 5 | 6 | const argv = require('./argv') 7 | const port = require('./port') 8 | const setup = require('./middlewares/frontendMiddleware') 9 | const isDev = process.env.NODE_ENV !== 'production' 10 | const ngrok = 11 | (isDev && process.env.ENABLE_TUNNEL) || argv.tunnel ? require('ngrok') : false 12 | const { resolve } = require('path') 13 | const app = express() 14 | 15 | // If you need a backend, e.g. an API, add your custom backend-specific middleware here 16 | // app.use('/api', myApi); 17 | 18 | // In production we need to pass these values in instead of relying on webpack 19 | setup(app, { 20 | outputPath: resolve(process.cwd(), 'build'), 21 | publicPath: '/' 22 | }) 23 | 24 | // get the intended host and port number, use localhost and port 3000 if not provided 25 | const customHost = argv.host || process.env.HOST 26 | const host = customHost || null // Let http.Server use its default IPv6/4 host 27 | const prettyHost = customHost || 'localhost' 28 | 29 | // use the gzipped bundle 30 | app.get('*.js', (req, res, next) => { 31 | req.url = req.url + '.gz'; // eslint-disable-line 32 | res.set('Content-Encoding', 'gzip') 33 | next() 34 | }) 35 | 36 | // Start your app. 37 | app.listen(port, host, async err => { 38 | if (err) { 39 | return logger.error(err.message) 40 | } 41 | 42 | // Connect to ngrok in dev mode 43 | if (ngrok) { 44 | let url 45 | try { 46 | url = await ngrok.connect(port) 47 | } catch (e) { 48 | return logger.error(e) 49 | } 50 | logger.appStarted(port, prettyHost, url) 51 | } else { 52 | logger.appStarted(port, prettyHost) 53 | } 54 | }) 55 | -------------------------------------------------------------------------------- /server/logger.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const chalk = require('chalk') 4 | const ip = require('ip') 5 | 6 | const divider = chalk.gray('\n-----------------------------------') 7 | 8 | /** 9 | * Logger middleware, you can customize it to make messages more personal 10 | */ 11 | const logger = { 12 | // Called whenever there's an error on the server we want to print 13 | error: err => { 14 | console.error(chalk.red(err)) 15 | }, 16 | 17 | // Called when express.js app starts on given port w/o errors 18 | appStarted: (port, host, tunnelStarted) => { 19 | console.log(`Server started ! ${chalk.green('✓')}`) 20 | 21 | // If the tunnel started, log that and the URL it's available at 22 | if (tunnelStarted) { 23 | console.log(`Tunnel initialised ${chalk.green('✓')}`) 24 | } 25 | 26 | console.log(` 27 | ${chalk.bold('Access URLs:')}${divider} 28 | Localhost: ${chalk.magenta(`http://${host}:${port}`)} 29 | LAN: ${chalk.magenta(`http://${ip.address()}:${port}`) + 30 | (tunnelStarted 31 | ? `\n Proxy: ${chalk.magenta(tunnelStarted)}` 32 | : '')}${divider} 33 | ${chalk.blue(`Press ${chalk.italic('CTRL-C')} to stop`)} 34 | `) 35 | } 36 | } 37 | 38 | module.exports = logger 39 | -------------------------------------------------------------------------------- /server/middlewares/addDevMiddlewares.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const webpackDevMiddleware = require('webpack-dev-middleware') 4 | const webpackHotMiddleware = require('webpack-hot-middleware') 5 | 6 | function createWebpackMiddleware(compiler, publicPath) { 7 | return webpackDevMiddleware(compiler, { 8 | publicPath, 9 | stats: 'errors-only' 10 | }) 11 | } 12 | 13 | module.exports = function addDevMiddlewares(app, webpackConfig) { 14 | const compiler = webpack(webpackConfig) 15 | const middleware = createWebpackMiddleware( 16 | compiler, 17 | webpackConfig.output.publicPath 18 | ) 19 | 20 | app.use(middleware) 21 | app.use(webpackHotMiddleware(compiler)) 22 | 23 | // Since webpackDevMiddleware uses memory-fs internally to store build 24 | // artifacts, we use it instead 25 | const fs = middleware.context.compiler.outputFileSystem 26 | 27 | app.get('*', (req, res) => { 28 | fs.readFile(path.join(compiler.outputPath, 'index.html'), (err, file) => { 29 | if (err) { 30 | res.sendStatus(404) 31 | } else { 32 | res.send(file.toString()) 33 | } 34 | }) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /server/middlewares/addProdMiddlewares.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const express = require('express') 3 | const compression = require('compression') 4 | 5 | module.exports = function addProdMiddlewares(app, options) { 6 | const publicPath = options.publicPath || '/' 7 | const outputPath = options.outputPath || path.resolve(process.cwd(), 'build') 8 | 9 | // compression middleware compresses your server responses which makes them 10 | // smaller (applies also to assets). You can read more about that technique 11 | // and other good practices on official Express.js docs http://mxs.is/googmy 12 | app.use(compression()) 13 | app.use(publicPath, express.static(outputPath)) 14 | 15 | app.get('*', (req, res) => 16 | res.sendFile(path.resolve(outputPath, 'index.html')) 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /server/middlewares/frontendMiddleware.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | /** 4 | * Front-end middleware 5 | */ 6 | module.exports = (app, options) => { 7 | const isProd = process.env.NODE_ENV === 'production'; 8 | 9 | if (isProd) { 10 | const addProdMiddlewares = require('./addProdMiddlewares'); 11 | addProdMiddlewares(app, options); 12 | } else { 13 | const webpackConfig = require('../../internals/webpack/webpack.config.dev'); 14 | const addDevMiddlewares = require('./addDevMiddlewares'); 15 | addDevMiddlewares(app, webpackConfig); 16 | } 17 | 18 | return app; 19 | }; 20 | -------------------------------------------------------------------------------- /server/port.js: -------------------------------------------------------------------------------- 1 | const argv = require('./argv') 2 | 3 | module.exports = parseInt(argv.port || process.env.PORT || '3000', 10) 4 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=wednesday-solutions 2 | sonar.projectKey=wednesday-solutions_react-template 3 | 4 | sonar.language=js 5 | sonar.sources=. 6 | sonar.tests=app 7 | sonar.exclusions=*./.storybook,*./internals,*./server 8 | sonar.test.inclusions=**/*.test.js 9 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 10 | sonar.testExecutionReportPaths=./reports/test-report.xml 11 | sonar.sourceEncoding=UTF-8 --------------------------------------------------------------------------------