├── .github ├── FUNDING.yml └── workflows │ └── issue-manager.yml ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cookiecutter.json ├── hooks └── post_gen_project.py ├── img ├── dashboard.png ├── docs.png ├── login.png └── redoc.png ├── scripts ├── dev-fsfp-back.sh ├── dev-fsfp.sh ├── dev-link.sh ├── discard-dev-files.sh ├── generate_cookiecutter_config.py └── test.sh └── {{cookiecutter.project_slug}} ├── .env ├── .gitignore ├── .gitlab-ci.yml ├── README.md ├── backend ├── .gitignore ├── app │ ├── .flake8 │ ├── .gitignore │ ├── alembic.ini │ ├── alembic │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── .keep │ │ │ └── d4867f3a4c0a_first_revision.py │ ├── app │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── api_v1 │ │ │ │ ├── __init__.py │ │ │ │ ├── api.py │ │ │ │ └── endpoints │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── items.py │ │ │ │ │ ├── login.py │ │ │ │ │ ├── users.py │ │ │ │ │ └── utils.py │ │ │ └── deps.py │ │ ├── backend_pre_start.py │ │ ├── celeryworker_pre_start.py │ │ ├── core │ │ │ ├── __init__.py │ │ │ ├── celery_app.py │ │ │ ├── config.py │ │ │ └── security.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── crud_item.py │ │ │ └── crud_user.py │ │ ├── db │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── base_class.py │ │ │ ├── init_db.py │ │ │ └── session.py │ │ ├── email-templates │ │ │ ├── build │ │ │ │ ├── new_account.html │ │ │ │ ├── reset_password.html │ │ │ │ └── test_email.html │ │ │ └── src │ │ │ │ ├── new_account.mjml │ │ │ │ ├── reset_password.mjml │ │ │ │ └── test_email.mjml │ │ ├── initial_data.py │ │ ├── main.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── item.py │ │ │ └── user.py │ │ ├── schemas │ │ │ ├── __init__.py │ │ │ ├── item.py │ │ │ ├── msg.py │ │ │ ├── token.py │ │ │ └── user.py │ │ ├── tests │ │ │ ├── .gitignore │ │ │ ├── __init__.py │ │ │ ├── api │ │ │ │ ├── __init__.py │ │ │ │ └── api_v1 │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── test_celery.py │ │ │ │ │ ├── test_items.py │ │ │ │ │ ├── test_login.py │ │ │ │ │ └── test_users.py │ │ │ ├── conftest.py │ │ │ ├── crud │ │ │ │ ├── __init__.py │ │ │ │ ├── test_item.py │ │ │ │ └── test_user.py │ │ │ └── utils │ │ │ │ ├── __init__.py │ │ │ │ ├── item.py │ │ │ │ ├── user.py │ │ │ │ └── utils.py │ │ ├── tests_pre_start.py │ │ ├── utils.py │ │ └── worker.py │ ├── mypy.ini │ ├── prestart.sh │ ├── pyproject.toml │ ├── scripts │ │ ├── format-imports.sh │ │ ├── format.sh │ │ ├── lint.sh │ │ ├── test-cov-html.sh │ │ └── test.sh │ ├── tests-start.sh │ └── worker-start.sh ├── backend.dockerfile └── celeryworker.dockerfile ├── cookiecutter-config-file.yml ├── docker-compose.override.yml ├── docker-compose.yml ├── frontend ├── .babelrc ├── .codeclimate.yml ├── .dockerignore ├── .editorconfig ├── .env ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .prettierignore ├── .stylelintrc ├── Dockerfile ├── LICENSE ├── README.md ├── assets │ ├── .htaccess │ ├── index.html │ ├── manifest.json │ ├── media │ │ ├── brand │ │ │ ├── icon.png │ │ │ ├── icon.svg │ │ │ ├── react-redux-saga.png │ │ │ └── react-redux-saga.svg │ │ ├── icons │ │ │ ├── bell-o.svg │ │ │ ├── bell.svg │ │ │ ├── bolt.svg │ │ │ ├── check-circle-o.svg │ │ │ ├── check-circle.svg │ │ │ ├── check.svg │ │ │ ├── dot-circle-o.svg │ │ │ ├── exclamation-circle.svg │ │ │ ├── question-circle-o.svg │ │ │ ├── question-circle.svg │ │ │ ├── safari-pinned-tab.svg │ │ │ ├── sign-in.svg │ │ │ ├── sign-out.svg │ │ │ ├── times-circle-o.svg │ │ │ ├── times-circle.svg │ │ │ └── times.svg │ │ ├── images │ │ │ └── og-image-v1.png │ │ └── meta-icons │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── icon-144x144.png │ │ │ ├── icon-192x192.png │ │ │ ├── icon-512x512.png │ │ │ ├── icon-96x96.png │ │ │ └── safari-pinned-tab.svg │ ├── robots.txt │ └── service-worker.js ├── config │ ├── env.js │ ├── paths.js │ ├── webpack.config.js │ └── webpackDevServer.js ├── cypress.json ├── cypress │ ├── e2e │ │ └── basic.js │ ├── fixtures │ │ └── example.json │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ └── index.js ├── jest.config.js ├── nginx-backend-not-found.conf ├── package-lock.json ├── package.json ├── src │ ├── App.jsx │ ├── actions │ │ ├── app.js │ │ ├── github.js │ │ ├── index.js │ │ └── user.js │ ├── components │ │ ├── Alert.jsx │ │ ├── Background.jsx │ │ ├── Footer.jsx │ │ ├── GitHub.jsx │ │ ├── GlobalStyles.jsx │ │ ├── Header.jsx │ │ ├── Icon.jsx │ │ ├── Loader.jsx │ │ ├── Logo.jsx │ │ ├── Reload.jsx │ │ ├── RoutePrivate.jsx │ │ ├── RoutePublic.jsx │ │ ├── SystemAlerts.jsx │ │ └── Transition │ │ │ ├── index.jsx │ │ │ └── transitions.js │ ├── config.js │ ├── constants │ │ └── index.js │ ├── index.jsx │ ├── modules │ │ ├── client.js │ │ ├── helpers.js │ │ ├── history.js │ │ └── theme.js │ ├── polyfills.js │ ├── reducers │ │ ├── app.js │ │ ├── github.js │ │ ├── index.js │ │ └── user.js │ ├── routes │ │ ├── Home.jsx │ │ ├── NotFound.jsx │ │ └── Private.jsx │ ├── sagas │ │ ├── app.js │ │ ├── github.js │ │ ├── index.js │ │ └── user.js │ ├── serviceWorker.js │ ├── store │ │ ├── index.js │ │ └── middleware.js │ └── vendor │ │ ├── modernizr-custom.js │ │ └── modernizrrc.json ├── test │ ├── .eslintrc │ ├── App.spec.js │ ├── __mocks__ │ │ ├── fileMock.js │ │ ├── history.js │ │ ├── moduleMock.js │ │ ├── react-router-dom.js │ │ ├── styleMock.js │ │ └── svgMock.js │ ├── __setup__ │ │ ├── setupFiles.js │ │ ├── setupTests.js │ │ └── withContext.js │ ├── __snapshots__ │ │ └── App.spec.js.snap │ ├── actions │ │ ├── __snapshots__ │ │ │ ├── app.spec.js.snap │ │ │ └── user.spec.js.snap │ │ ├── app.spec.js │ │ └── user.spec.js │ ├── components │ │ ├── Alert.spec.js │ │ ├── Background.spec.js │ │ ├── Footer.spec.js │ │ ├── GitHub.spec.js │ │ ├── GlobalStyles.spec.js │ │ ├── Header.spec.js │ │ ├── Icon.spec.js │ │ ├── Loader.spec.js │ │ ├── Logo.spec.js │ │ ├── Reload.spec.js │ │ ├── RoutePrivate.spec.js │ │ ├── RoutePublic.spec.js │ │ ├── SystemAlerts.spec.js │ │ ├── Transition │ │ │ ├── __snapshots__ │ │ │ │ └── index.spec.js.snap │ │ │ └── index.spec.js │ │ └── __snapshots__ │ │ │ ├── Alert.spec.js.snap │ │ │ ├── Background.spec.js.snap │ │ │ ├── Footer.spec.js.snap │ │ │ ├── GitHub.spec.js.snap │ │ │ ├── GlobalStyles.spec.js.snap │ │ │ ├── Header.spec.js.snap │ │ │ ├── Icon.spec.js.snap │ │ │ ├── Loader.spec.js.snap │ │ │ ├── Logo.spec.js.snap │ │ │ ├── Reload.spec.js.snap │ │ │ ├── RoutePrivate.spec.js.snap │ │ │ ├── RoutePublic.spec.js.snap │ │ │ └── SystemAlerts.spec.js.snap │ ├── constants │ │ ├── __snapshots__ │ │ │ └── index.spec.js.snap │ │ └── index.spec.js │ ├── index.spec.js │ ├── modules │ │ ├── RoutePrivate.spec.js │ │ ├── RoutePublic.spec.js │ │ ├── __snapshots__ │ │ │ ├── RoutePrivate.spec.js.snap │ │ │ ├── RoutePublic.spec.js.snap │ │ │ ├── client.spec.js.snap │ │ │ └── helpers.spec.js.snap │ │ ├── client.spec.js │ │ └── helpers.spec.js │ ├── reducers │ │ ├── __snapshots__ │ │ │ ├── app.spec.js.snap │ │ │ ├── github.spec.js.snap │ │ │ └── user.spec.js.snap │ │ ├── app.spec.js │ │ ├── github.spec.js │ │ └── user.spec.js │ ├── routes │ │ ├── Home.spec.js │ │ ├── NotFound.spec.js │ │ ├── Private.spec.js │ │ └── __snapshots__ │ │ │ ├── Home.spec.js.snap │ │ │ ├── NotFound.spec.js.snap │ │ │ └── Private.spec.js.snap │ ├── sagas │ │ ├── __snapshots__ │ │ │ ├── app.spec.js.snap │ │ │ ├── github.spec.js.snap │ │ │ └── user.spec.js.snap │ │ ├── app.spec.js │ │ ├── github.spec.js │ │ └── user.spec.js │ └── store │ │ ├── __snapshots__ │ │ └── index.spec.js.snap │ │ └── index.spec.js └── tools │ ├── build.js │ ├── deploy.js │ ├── publish.js │ └── start.js └── scripts ├── build-push.sh ├── build.sh ├── deploy.sh ├── test-local.sh └── test.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [isakbosman] 2 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: Issue Manager 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | issue_comment: 7 | types: 8 | - created 9 | - edited 10 | issues: 11 | types: 12 | - labeled 13 | 14 | jobs: 15 | issue-manager: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: isakbosman/issue-manager@0.2.0 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | config: > 22 | { 23 | "answered": { 24 | "users": ["isakbosman"], 25 | "delay": 864000, 26 | "message": "Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues." 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | testing-project 3 | .mypy_cache 4 | poetry.lock 5 | dev-link/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: python 4 | 5 | install: 6 | - pip install cookiecutter 7 | 8 | services: 9 | - docker 10 | 11 | script: 12 | - bash ./scripts/test.sh 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sebastián Ramírez 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 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "Base Project", 3 | "project_slug": "{{ cookiecutter.project_name|lower|replace(' ', '-') }}", 4 | "domain_main": "{{cookiecutter.project_slug}}.com", 5 | "domain_staging": "stag.{{cookiecutter.domain_main}}", 6 | 7 | "docker_swarm_stack_name_main": "{{cookiecutter.domain_main|replace('.', '-')}}", 8 | "docker_swarm_stack_name_staging": "{{cookiecutter.domain_staging|replace('.', '-')}}", 9 | 10 | "secret_key": "changethis", 11 | "first_superuser": "admin@{{cookiecutter.domain_main}}", 12 | "first_superuser_password": "changethis", 13 | "backend_cors_origins": "[\"http://localhost\", \"http://localhost:4200\", \"http://localhost:3000\", \"http://localhost:8080\", \"https://localhost\", \"https://localhost:4200\", \"https://localhost:3000\", \"https://localhost:8080\", \"http://dev.{{cookiecutter.domain_main}}\", \"https://{{cookiecutter.domain_staging}}\", \"https://{{cookiecutter.domain_main}}\", \"http://local.dockertoolbox.isakbosman.com\", \"http://localhost.isakbosman.com\"]", 14 | "smtp_port": "587", 15 | "smtp_host": "", 16 | "smtp_user": "", 17 | "smtp_password": "", 18 | "smtp_emails_from_email": "info@{{cookiecutter.domain_main}}", 19 | 20 | "postgres_password": "changethis", 21 | "pgadmin_default_user": "{{cookiecutter.first_superuser}}", 22 | "pgadmin_default_user_password": "{{cookiecutter.first_superuser_password}}", 23 | 24 | "traefik_constraint_tag": "{{cookiecutter.domain_main}}", 25 | "traefik_constraint_tag_staging": "{{cookiecutter.domain_staging}}", 26 | "traefik_public_constraint_tag": "traefik-public", 27 | 28 | "flower_auth": "admin:{{cookiecutter.first_superuser_password}}", 29 | 30 | "sentry_dsn": "", 31 | 32 | "docker_image_prefix": "", 33 | 34 | "docker_image_backend": "{{cookiecutter.docker_image_prefix}}backend", 35 | "docker_image_celeryworker": "{{cookiecutter.docker_image_prefix}}celeryworker", 36 | "docker_image_frontend": "{{cookiecutter.docker_image_prefix}}frontend", 37 | 38 | "_copy_without_render": [ 39 | "frontend/src/**/*.html", 40 | "frontend/src/**/*.js", 41 | "frontend/src/**/*.jsx", 42 | "frontend/node_modules/*", 43 | "backend/app/app/email-templates/**" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | path: Path 5 | for path in Path(".").glob("**/*.sh"): 6 | data = path.read_bytes() 7 | lf_data = data.replace(b"\r\n", b"\n") 8 | path.write_bytes(lf_data) 9 | -------------------------------------------------------------------------------- /img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/img/dashboard.png -------------------------------------------------------------------------------- /img/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/img/docs.png -------------------------------------------------------------------------------- /img/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/img/login.png -------------------------------------------------------------------------------- /img/redoc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/img/redoc.png -------------------------------------------------------------------------------- /scripts/dev-fsfp-back.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Run this script from outside the project, to integrate a dev-fsfp project with changes and review modifications 4 | 5 | # Exit in case of error 6 | set -e 7 | 8 | if [ ! -d ./full-stack-fastapi-postgresql ] ; then 9 | echo "Run this script from outside the project, to integrate a sibling dev-fsfp project with changes and review modifications" 10 | exit 1 11 | fi 12 | 13 | if [ $(uname -s) = "Linux" ]; then 14 | echo "Remove __pycache__ files" 15 | sudo find ./dev-fsfp/ -type d -name __pycache__ -exec rm -r {} \+ 16 | fi 17 | 18 | rm -rf ./full-stack-fastapi-postgresql/\{\{cookiecutter.project_slug\}\}/* 19 | 20 | rsync -a --exclude=node_modules ./dev-fsfp/* ./full-stack-fastapi-postgresql/\{\{cookiecutter.project_slug\}\}/ 21 | 22 | rsync -a ./dev-fsfp/{.env,.gitignore,.gitlab-ci.yml} ./full-stack-fastapi-postgresql/\{\{cookiecutter.project_slug\}\}/ 23 | -------------------------------------------------------------------------------- /scripts/dev-fsfp.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | if [ ! -d ./full-stack-fastapi-postgresql ] ; then 7 | echo "Run this script from outside the project, to generate a sibling dev-fsfp project with independent git" 8 | exit 1 9 | fi 10 | 11 | rm -rf ./dev-fsfp 12 | 13 | cookiecutter --no-input -f ./full-stack-fastapi-postgresql project_name="Dev FSFP" 14 | -------------------------------------------------------------------------------- /scripts/dev-link.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | # Run this from the root of the project to generate a dev-link project 7 | # It will contain a link to each of the files of the generator, except for 8 | # .env and frontend/.env, that will be the generated ones 9 | # This allows developing with a live stack while keeping the same source code 10 | # Without having to generate dev-fsfp and integrating back all the files 11 | 12 | rm -rf dev-link 13 | mkdir -p tmp-dev-link/frontend 14 | 15 | cookiecutter --no-input -f ./ project_name="Dev Link" 16 | 17 | mv ./dev-link/.env ./tmp-dev-link/ 18 | mv ./dev-link/frontend/.env ./tmp-dev-link/frontend/ 19 | 20 | rm -rf ./dev-link/ 21 | mkdir -p ./dev-link/ 22 | 23 | cd ./dev-link/ 24 | 25 | for f in ../\{\{cookiecutter.project_slug\}\}/* ; do 26 | ln -s "$f" ./ 27 | done 28 | 29 | cd .. 30 | 31 | mv ./tmp-dev-link/.env ./dev-link/ 32 | mv ./tmp-dev-link/frontend/.env ./dev-link/frontend/ 33 | 34 | rm -rf ./tmp-dev-link 35 | -------------------------------------------------------------------------------- /scripts/discard-dev-files.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | 5 | rm -rf \{\{cookiecutter.project_slug\}\}/.git 6 | rm -rf \{\{cookiecutter.project_slug\}\}/backend/app/poetry.lock 7 | rm -rf \{\{cookiecutter.project_slug\}\}/frontend/node_modules 8 | rm -rf \{\{cookiecutter.project_slug\}\}/frontend/dist 9 | git checkout \{\{cookiecutter.project_slug\}\}/README.md 10 | git checkout \{\{cookiecutter.project_slug\}\}/.gitlab-ci.yml 11 | git checkout \{\{cookiecutter.project_slug\}\}/cookiecutter-config-file.yml 12 | git checkout \{\{cookiecutter.project_slug\}\}/.env 13 | git checkout \{\{cookiecutter.project_slug\}\}/frontend/.env 14 | -------------------------------------------------------------------------------- /scripts/generate_cookiecutter_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict 3 | import oyaml as yaml 4 | from pathlib import Path 5 | cookie_path = Path('./cookiecutter.json') 6 | out_path = Path('./{{cookiecutter.project_slug}}/cookiecutter-config-file.yml') 7 | 8 | with open(cookie_path) as f: 9 | cookie_config = json.load(f) 10 | config_out = OrderedDict() 11 | 12 | for key, value in cookie_config.items(): 13 | if key.startswith('_'): 14 | config_out[key] = value 15 | else: 16 | config_out[key] = '{{ cookiecutter.' + key + ' }}' 17 | config_out['_template'] = './' 18 | 19 | with open(out_path, 'w') as out_f: 20 | out_f.write(yaml.dump({'default_context': config_out}, line_break=None, width=200)) 21 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | # Run this from the root of the project 7 | 8 | rm -rf ./testing-project 9 | 10 | cookiecutter --no-input -f ./ project_name="Testing Project" 11 | 12 | cd ./testing-project 13 | 14 | bash ./scripts/test.sh "$@" 15 | 16 | cd ../ 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.env: -------------------------------------------------------------------------------- 1 | DOMAIN=localhost 2 | # DOMAIN=local.dockertoolbox.tiangolo.com 3 | # DOMAIN=localhost.tiangolo.com 4 | # DOMAIN=dev.{{cookiecutter.domain_main}} 5 | 6 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}} 7 | 8 | TRAEFIK_PUBLIC_NETWORK=traefik-public 9 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} 10 | TRAEFIK_PUBLIC_TAG={{cookiecutter.traefik_public_constraint_tag}} 11 | 12 | DOCKER_IMAGE_BACKEND={{cookiecutter.docker_image_backend}} 13 | DOCKER_IMAGE_CELERYWORKER={{cookiecutter.docker_image_celeryworker}} 14 | DOCKER_IMAGE_FRONTEND={{cookiecutter.docker_image_frontend}} 15 | 16 | # Backend 17 | BACKEND_CORS_ORIGINS={{cookiecutter.backend_cors_origins}} 18 | PROJECT_NAME={{cookiecutter.project_name}} 19 | SECRET_KEY={{cookiecutter.secret_key}} 20 | FIRST_SUPERUSER={{cookiecutter.first_superuser}} 21 | FIRST_SUPERUSER_PASSWORD={{cookiecutter.first_superuser_password}} 22 | SMTP_TLS=True 23 | SMTP_PORT={{cookiecutter.smtp_port}} 24 | SMTP_HOST={{cookiecutter.smtp_host}} 25 | SMTP_USER={{cookiecutter.smtp_user}} 26 | SMTP_PASSWORD={{cookiecutter.smtp_password}} 27 | EMAILS_FROM_EMAIL={{cookiecutter.smtp_emails_from_email}} 28 | 29 | USERS_OPEN_REGISTRATION=False 30 | 31 | SENTRY_DSN={{cookiecutter.sentry_dsn}} 32 | 33 | # Flower 34 | FLOWER_BASIC_AUTH={{cookiecutter.flower_auth}} 35 | 36 | # Postgres 37 | POSTGRES_SERVER=db 38 | POSTGRES_USER=postgres 39 | POSTGRES_PASSWORD={{cookiecutter.postgres_password}} 40 | POSTGRES_DB=app 41 | 42 | # PgAdmin 43 | PGADMIN_LISTEN_PORT=5050 44 | PGADMIN_DEFAULT_EMAIL={{cookiecutter.pgadmin_default_user}} 45 | PGADMIN_DEFAULT_PASSWORD={{cookiecutter.pgadmin_default_user_password}} 46 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .mypy_cache 3 | docker-stack.yml 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: tiangolo/docker-with-compose 2 | 3 | before_script: 4 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY 5 | - pip install docker-auto-labels 6 | 7 | stages: 8 | - test 9 | - build 10 | - deploy 11 | 12 | tests: 13 | stage: test 14 | script: 15 | - sh ./scripts/test.sh 16 | tags: 17 | - build 18 | - test 19 | 20 | build-stag: 21 | stage: build 22 | script: 23 | - TAG=stag FRONTEND_ENV=staging sh ./scripts/build-push.sh 24 | only: 25 | - master 26 | tags: 27 | - build 28 | - test 29 | 30 | build-prod: 31 | stage: build 32 | script: 33 | - TAG=prod FRONTEND_ENV=production sh ./scripts/build-push.sh 34 | only: 35 | - production 36 | tags: 37 | - build 38 | - test 39 | 40 | deploy-stag: 41 | stage: deploy 42 | script: 43 | - > 44 | DOMAIN={{cookiecutter.domain_staging}} 45 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag_staging}} 46 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_staging}} 47 | TAG=stag 48 | sh ./scripts/deploy.sh 49 | environment: 50 | name: staging 51 | url: https://{{cookiecutter.domain_staging}} 52 | only: 53 | - master 54 | tags: 55 | - swarm 56 | - stag 57 | 58 | deploy-prod: 59 | stage: deploy 60 | script: 61 | - > 62 | DOMAIN={{cookiecutter.domain_main}} 63 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} 64 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}} 65 | TAG=prod 66 | sh ./scripts/deploy.sh 67 | environment: 68 | name: production 69 | url: https://{{cookiecutter.domain_main}} 70 | only: 71 | - production 72 | tags: 73 | - swarm 74 | - prod 75 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | app.egg-info 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .coverage 3 | htmlcov 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # Logging configuration 39 | [loggers] 40 | keys = root,sqlalchemy,alembic 41 | 42 | [handlers] 43 | keys = console 44 | 45 | [formatters] 46 | keys = generic 47 | 48 | [logger_root] 49 | level = WARN 50 | handlers = console 51 | qualname = 52 | 53 | [logger_sqlalchemy] 54 | level = WARN 55 | handlers = 56 | qualname = sqlalchemy.engine 57 | 58 | [logger_alembic] 59 | level = INFO 60 | handlers = 61 | qualname = alembic 62 | 63 | [handler_console] 64 | class = StreamHandler 65 | args = (sys.stderr,) 66 | level = NOTSET 67 | formatter = generic 68 | 69 | [formatter_generic] 70 | format = %(levelname)-5.5s [%(name)s] %(message)s 71 | datefmt = %H:%M:%S 72 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/versions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/alembic/versions/.keep -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py: -------------------------------------------------------------------------------- 1 | """First revision 2 | 3 | Revision ID: d4867f3a4c0a 4 | Revises: 5 | Create Date: 2019-04-17 13:53:32.978401 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "d4867f3a4c0a" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "user", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("full_name", sa.String(), nullable=True), 25 | sa.Column("email", sa.String(), nullable=True), 26 | sa.Column("hashed_password", sa.String(), nullable=True), 27 | sa.Column("is_active", sa.Boolean(), nullable=True), 28 | sa.Column("is_superuser", sa.Boolean(), nullable=True), 29 | sa.PrimaryKeyConstraint("id"), 30 | ) 31 | op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) 32 | op.create_index(op.f("ix_user_full_name"), "user", ["full_name"], unique=False) 33 | op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False) 34 | op.create_table( 35 | "item", 36 | sa.Column("id", sa.Integer(), nullable=False), 37 | sa.Column("title", sa.String(), nullable=True), 38 | sa.Column("description", sa.String(), nullable=True), 39 | sa.Column("owner_id", sa.Integer(), nullable=True), 40 | sa.ForeignKeyConstraint(["owner_id"], ["user.id"],), 41 | sa.PrimaryKeyConstraint("id"), 42 | ) 43 | op.create_index(op.f("ix_item_description"), "item", ["description"], unique=False) 44 | op.create_index(op.f("ix_item_id"), "item", ["id"], unique=False) 45 | op.create_index(op.f("ix_item_title"), "item", ["title"], unique=False) 46 | # ### end Alembic commands ### 47 | 48 | 49 | def downgrade(): 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.drop_index(op.f("ix_item_title"), table_name="item") 52 | op.drop_index(op.f("ix_item_id"), table_name="item") 53 | op.drop_index(op.f("ix_item_description"), table_name="item") 54 | op.drop_table("item") 55 | op.drop_index(op.f("ix_user_id"), table_name="user") 56 | op.drop_index(op.f("ix_user_full_name"), table_name="user") 57 | op.drop_index(op.f("ix_user_email"), table_name="user") 58 | op.drop_table("user") 59 | # ### end Alembic commands ### 60 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.api_v1.endpoints import items, login, users, utils 4 | 5 | api_router = APIRouter() 6 | api_router.include_router(login.router, tags=["login"]) 7 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 8 | api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) 9 | api_router.include_router(items.router, prefix="/items", tags=["items"]) 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter, Depends 4 | from pydantic.networks import EmailStr 5 | 6 | from app import models, schemas 7 | from app.api import deps 8 | from app.core.celery_app import celery_app 9 | from app.utils import send_test_email 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.post("/test-celery/", response_model=schemas.Msg, status_code=201) 15 | def test_celery( 16 | msg: schemas.Msg, 17 | current_user: models.User = Depends(deps.get_current_active_superuser), 18 | ) -> Any: 19 | """ 20 | Test Celery worker. 21 | """ 22 | celery_app.send_task("app.worker.test_celery", args=[msg.msg]) 23 | return {"msg": "Word received"} 24 | 25 | 26 | @router.post("/test-email/", response_model=schemas.Msg, status_code=201) 27 | def test_email( 28 | email_to: EmailStr, 29 | current_user: models.User = Depends(deps.get_current_active_superuser), 30 | ) -> Any: 31 | """ 32 | Test emails. 33 | """ 34 | send_test_email(email_to=email_to) 35 | return {"msg": "Test email sent"} 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/deps.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | from fastapi import Depends, HTTPException, status 4 | from fastapi.security import OAuth2PasswordBearer 5 | from jose import jwt 6 | from pydantic import ValidationError 7 | from sqlalchemy.orm import Session 8 | 9 | from app import crud, models, schemas 10 | from app.core import security 11 | from app.core.config import settings 12 | from app.db.session import SessionLocal 13 | 14 | reusable_oauth2 = OAuth2PasswordBearer( 15 | tokenUrl=f"{settings.API_V1_STR}/login/access-token" 16 | ) 17 | 18 | 19 | def get_db() -> Generator: 20 | try: 21 | db = SessionLocal() 22 | yield db 23 | finally: 24 | db.close() 25 | 26 | 27 | def get_current_user( 28 | db: Session = Depends(get_db), token: str = Depends(reusable_oauth2) 29 | ) -> models.User: 30 | try: 31 | payload = jwt.decode( 32 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] 33 | ) 34 | token_data = schemas.TokenPayload(**payload) 35 | except (jwt.JWTError, ValidationError): 36 | raise HTTPException( 37 | status_code=status.HTTP_403_FORBIDDEN, 38 | detail="Could not validate credentials", 39 | ) 40 | user = crud.user.get(db, id=token_data.sub) 41 | if not user: 42 | raise HTTPException(status_code=404, detail="User not found") 43 | return user 44 | 45 | 46 | def get_current_active_user( 47 | current_user: models.User = Depends(get_current_user), 48 | ) -> models.User: 49 | if not crud.user.is_active(current_user): 50 | raise HTTPException(status_code=400, detail="Inactive user") 51 | return current_user 52 | 53 | 54 | def get_current_active_superuser( 55 | current_user: models.User = Depends(get_current_user), 56 | ) -> models.User: 57 | if not crud.user.is_superuser(current_user): 58 | raise HTTPException( 59 | status_code=400, detail="The user doesn't have enough privileges" 60 | ) 61 | return current_user 62 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.session import SessionLocal 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 60 * 5 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | def init() -> None: 21 | try: 22 | db = SessionLocal() 23 | # Try to create session to check if DB is awake 24 | db.execute("SELECT 1") 25 | except Exception as e: 26 | logger.error(e) 27 | raise e 28 | 29 | 30 | def main() -> None: 31 | logger.info("Initializing service") 32 | init() 33 | logger.info("Service finished initializing") 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.session import SessionLocal 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 60 * 5 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | def init() -> None: 21 | try: 22 | # Try to create session to check if DB is awake 23 | db = SessionLocal() 24 | db.execute("SELECT 1") 25 | except Exception as e: 26 | logger.error(e) 27 | raise e 28 | 29 | 30 | def main() -> None: 31 | logger.info("Initializing service") 32 | init() 33 | logger.info("Service finished initializing") 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/celery_app.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | celery_app = Celery("worker", broker="amqp://guest@queue//") 4 | 5 | celery_app.conf.task_routes = {"app.worker.test_celery": "main-queue"} 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Union 3 | 4 | from jose import jwt 5 | from passlib.context import CryptContext 6 | 7 | from app.core.config import settings 8 | 9 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 10 | 11 | 12 | ALGORITHM = "HS256" 13 | 14 | 15 | def create_access_token( 16 | subject: Union[str, Any], expires_delta: timedelta = None 17 | ) -> str: 18 | if expires_delta: 19 | expire = datetime.utcnow() + expires_delta 20 | else: 21 | expire = datetime.utcnow() + timedelta( 22 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES 23 | ) 24 | to_encode = {"exp": expire, "sub": str(subject)} 25 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) 26 | return encoded_jwt 27 | 28 | 29 | def verify_password(plain_password: str, hashed_password: str) -> bool: 30 | return pwd_context.verify(plain_password, hashed_password) 31 | 32 | 33 | def get_password_hash(password: str) -> str: 34 | return pwd_context.hash(password) 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .crud_item import item 2 | from .crud_user import user 3 | 4 | # For a new basic set of CRUD operations you could just do 5 | 6 | # from .base import CRUDBase 7 | # from app.models.item import Item 8 | # from app.schemas.item import ItemCreate, ItemUpdate 9 | 10 | # item = CRUDBase[Item, ItemCreate, ItemUpdate](Item) 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic import BaseModel 5 | from sqlalchemy.orm import Session 6 | 7 | from app.db.base_class import Base 8 | 9 | ModelType = TypeVar("ModelType", bound=Base) 10 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 11 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 12 | 13 | 14 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 15 | def __init__(self, model: Type[ModelType]): 16 | """ 17 | CRUD object with default methods to Create, Read, Update, Delete (CRUD). 18 | 19 | **Parameters** 20 | 21 | * `model`: A SQLAlchemy model class 22 | * `schema`: A Pydantic model (schema) class 23 | """ 24 | self.model = model 25 | 26 | def get(self, db: Session, id: Any) -> Optional[ModelType]: 27 | return db.query(self.model).filter(self.model.id == id).first() 28 | 29 | def get_multi( 30 | self, db: Session, *, skip: int = 0, limit: int = 100 31 | ) -> List[ModelType]: 32 | return db.query(self.model).offset(skip).limit(limit).all() 33 | 34 | def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: 35 | obj_in_data = jsonable_encoder(obj_in) 36 | db_obj = self.model(**obj_in_data) # type: ignore 37 | db.add(db_obj) 38 | db.commit() 39 | db.refresh(db_obj) 40 | return db_obj 41 | 42 | def update( 43 | self, 44 | db: Session, 45 | *, 46 | db_obj: ModelType, 47 | obj_in: Union[UpdateSchemaType, Dict[str, Any]] 48 | ) -> ModelType: 49 | obj_data = jsonable_encoder(db_obj) 50 | if isinstance(obj_in, dict): 51 | update_data = obj_in 52 | else: 53 | update_data = obj_in.dict(exclude_unset=True) 54 | for field in obj_data: 55 | if field in update_data: 56 | setattr(db_obj, field, update_data[field]) 57 | db.add(db_obj) 58 | db.commit() 59 | db.refresh(db_obj) 60 | return db_obj 61 | 62 | def remove(self, db: Session, *, id: int) -> ModelType: 63 | obj = db.query(self.model).get(id) 64 | db.delete(obj) 65 | db.commit() 66 | return obj 67 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/crud_item.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from sqlalchemy.orm import Session 5 | 6 | from app.crud.base import CRUDBase 7 | from app.models.item import Item 8 | from app.schemas.item import ItemCreate, ItemUpdate 9 | 10 | 11 | class CRUDItem(CRUDBase[Item, ItemCreate, ItemUpdate]): 12 | def create_with_owner( 13 | self, db: Session, *, obj_in: ItemCreate, owner_id: int 14 | ) -> Item: 15 | obj_in_data = jsonable_encoder(obj_in) 16 | db_obj = self.model(**obj_in_data, owner_id=owner_id) 17 | db.add(db_obj) 18 | db.commit() 19 | db.refresh(db_obj) 20 | return db_obj 21 | 22 | def get_multi_by_owner( 23 | self, db: Session, *, owner_id: int, skip: int = 0, limit: int = 100 24 | ) -> List[Item]: 25 | return ( 26 | db.query(self.model) 27 | .filter(Item.owner_id == owner_id) 28 | .offset(skip) 29 | .limit(limit) 30 | .all() 31 | ) 32 | 33 | 34 | item = CRUDItem(Item) 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Union 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from app.core.security import get_password_hash, verify_password 6 | from app.crud.base import CRUDBase 7 | from app.models.user import User 8 | from app.schemas.user import UserCreate, UserUpdate 9 | 10 | 11 | class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): 12 | def get_by_email(self, db: Session, *, email: str) -> Optional[User]: 13 | return db.query(User).filter(User.email == email).first() 14 | 15 | def create(self, db: Session, *, obj_in: UserCreate) -> User: 16 | db_obj = User( 17 | email=obj_in.email, 18 | hashed_password=get_password_hash(obj_in.password), 19 | full_name=obj_in.full_name, 20 | is_superuser=obj_in.is_superuser, 21 | ) 22 | db.add(db_obj) 23 | db.commit() 24 | db.refresh(db_obj) 25 | return db_obj 26 | 27 | def update( 28 | self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] 29 | ) -> User: 30 | if isinstance(obj_in, dict): 31 | update_data = obj_in 32 | else: 33 | update_data = obj_in.dict(exclude_unset=True) 34 | if update_data["password"]: 35 | hashed_password = get_password_hash(update_data["password"]) 36 | del update_data["password"] 37 | update_data["hashed_password"] = hashed_password 38 | return super().update(db, db_obj=db_obj, obj_in=update_data) 39 | 40 | def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]: 41 | user = self.get_by_email(db, email=email) 42 | if not user: 43 | return None 44 | if not verify_password(password, user.hashed_password): 45 | return None 46 | return user 47 | 48 | def is_active(self, user: User) -> bool: 49 | return user.is_active 50 | 51 | def is_superuser(self, user: User) -> bool: 52 | return user.is_superuser 53 | 54 | 55 | user = CRUDUser(User) 56 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/base.py: -------------------------------------------------------------------------------- 1 | # Import all the models, so that Base has them before being 2 | # imported by Alembic 3 | from app.db.base_class import Base # noqa 4 | from app.models.item import Item # noqa 5 | from app.models.user import User # noqa 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy.ext.declarative import as_declarative, declared_attr 4 | 5 | 6 | @as_declarative() 7 | class Base: 8 | id: Any 9 | __name__: str 10 | # Generate __tablename__ automatically 11 | @declared_attr 12 | def __tablename__(cls) -> str: 13 | return cls.__name__.lower() 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from app import crud, schemas 4 | from app.core.config import settings 5 | from app.db import base # noqa: F401 6 | 7 | # make sure all SQL Alchemy models are imported (app.db.base) before initializing DB 8 | # otherwise, SQL Alchemy might fail to initialize relationships properly 9 | # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 10 | 11 | 12 | def init_db(db: Session) -> None: 13 | # Tables should be created with Alembic migrations 14 | # But if you don't want to use migrations, create 15 | # the tables un-commenting the next line 16 | # Base.metadata.create_all(bind=engine) 17 | 18 | user = crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER) 19 | if not user: 20 | user_in = schemas.UserCreate( 21 | email=settings.FIRST_SUPERUSER, 22 | password=settings.FIRST_SUPERUSER_PASSWORD, 23 | is_superuser=True, 24 | ) 25 | user = crud.user.create(db, obj_in=user_in) # noqa: F841 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker 3 | 4 | from app.core.config import settings 5 | 6 | engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True) 7 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 8 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/new_account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - New Account 7 | You have a new account: 8 | Username: {{ username }} 9 | Password: {{ password }} 10 | Go to Dashboard 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/reset_password.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - Password Recovery 7 | We received a request to recover the password for user {{ username }} 8 | with email {{ email }} 9 | Reset your password by clicking the button below: 10 | Reset Password 11 | Or open the following link: 12 | {{ link }} 13 | 14 | The reset password link / button will expire in {{ valid_hours }} hours. 15 | If you didn't request a password recovery you can disregard this email. 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/test_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} 7 | Test email for: {{ email }} 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/initial_data.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.db.init_db import init_db 4 | from app.db.session import SessionLocal 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def init() -> None: 11 | db = SessionLocal() 12 | init_db(db) 13 | 14 | 15 | def main() -> None: 16 | logger.info("Creating initial data") 17 | init() 18 | logger.info("Initial data created") 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.middleware.cors import CORSMiddleware 3 | 4 | from app.api.api_v1.api import api_router 5 | from app.core.config import settings 6 | 7 | app = FastAPI( 8 | title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json" 9 | ) 10 | 11 | # Set all CORS enabled origins 12 | if settings.BACKEND_CORS_ORIGINS: 13 | app.add_middleware( 14 | CORSMiddleware, 15 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 16 | allow_credentials=True, 17 | allow_methods=["*"], 18 | allow_headers=["*"], 19 | ) 20 | 21 | app.include_router(api_router, prefix=settings.API_V1_STR) 22 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .item import Item 2 | from .user import User 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/item.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from sqlalchemy import Column, ForeignKey, Integer, String 4 | from sqlalchemy.orm import relationship 5 | 6 | from app.db.base_class import Base 7 | 8 | if TYPE_CHECKING: 9 | from .user import User # noqa: F401 10 | 11 | 12 | class Item(Base): 13 | id = Column(Integer, primary_key=True, index=True) 14 | title = Column(String, index=True) 15 | description = Column(String, index=True) 16 | owner_id = Column(Integer, ForeignKey("user.id")) 17 | owner = relationship("User", back_populates="items") 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from sqlalchemy import Boolean, Column, Integer, String 4 | from sqlalchemy.orm import relationship 5 | 6 | from app.db.base_class import Base 7 | 8 | if TYPE_CHECKING: 9 | from .item import Item # noqa: F401 10 | 11 | 12 | class User(Base): 13 | id = Column(Integer, primary_key=True, index=True) 14 | full_name = Column(String, index=True) 15 | email = Column(String, unique=True, index=True, nullable=False) 16 | hashed_password = Column(String, nullable=False) 17 | is_active = Column(Boolean(), default=True) 18 | is_superuser = Column(Boolean(), default=False) 19 | items = relationship("Item", back_populates="owner") 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .item import Item, ItemCreate, ItemInDB, ItemUpdate 2 | from .msg import Msg 3 | from .token import Token, TokenPayload 4 | from .user import User, UserCreate, UserInDB, UserUpdate 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | # Shared properties 7 | class ItemBase(BaseModel): 8 | title: Optional[str] = None 9 | description: Optional[str] = None 10 | 11 | 12 | # Properties to receive on item creation 13 | class ItemCreate(ItemBase): 14 | title: str 15 | 16 | 17 | # Properties to receive on item update 18 | class ItemUpdate(ItemBase): 19 | pass 20 | 21 | 22 | # Properties shared by models stored in DB 23 | class ItemInDBBase(ItemBase): 24 | id: int 25 | title: str 26 | owner_id: int 27 | 28 | class Config: 29 | orm_mode = True 30 | 31 | 32 | # Properties to return to client 33 | class Item(ItemInDBBase): 34 | pass 35 | 36 | 37 | # Properties properties stored in DB 38 | class ItemInDB(ItemInDBBase): 39 | pass 40 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/msg.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Msg(BaseModel): 5 | msg: str 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Token(BaseModel): 7 | access_token: str 8 | token_type: str 9 | 10 | 11 | class TokenPayload(BaseModel): 12 | sub: Optional[int] = None 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, EmailStr 4 | 5 | 6 | # Shared properties 7 | class UserBase(BaseModel): 8 | email: Optional[EmailStr] = None 9 | is_active: Optional[bool] = True 10 | is_superuser: bool = False 11 | full_name: Optional[str] = None 12 | 13 | 14 | # Properties to receive via API on creation 15 | class UserCreate(UserBase): 16 | email: EmailStr 17 | password: str 18 | 19 | 20 | # Properties to receive via API on update 21 | class UserUpdate(UserBase): 22 | password: Optional[str] = None 23 | 24 | 25 | class UserInDBBase(UserBase): 26 | id: Optional[int] = None 27 | 28 | class Config: 29 | orm_mode = True 30 | 31 | 32 | # Additional properties to return via API 33 | class User(UserInDBBase): 34 | pass 35 | 36 | 37 | # Additional properties stored in DB 38 | class UserInDB(UserInDBBase): 39 | hashed_password: str 40 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.core.config import settings 6 | 7 | 8 | def test_celery_worker_test( 9 | client: TestClient, superuser_token_headers: Dict[str, str] 10 | ) -> None: 11 | data = {"msg": "test"} 12 | r = client.post( 13 | f"{settings.API_V1_STR}/utils/test-celery/", 14 | json=data, 15 | headers=superuser_token_headers, 16 | ) 17 | response = r.json() 18 | assert response["msg"] == "Word received" 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from sqlalchemy.orm import Session 3 | 4 | from app.core.config import settings 5 | from app.tests.utils.item import create_random_item 6 | 7 | 8 | def test_create_item( 9 | client: TestClient, superuser_token_headers: dict, db: Session 10 | ) -> None: 11 | data = {"title": "Foo", "description": "Fighters"} 12 | response = client.post( 13 | f"{settings.API_V1_STR}/items/", headers=superuser_token_headers, json=data, 14 | ) 15 | assert response.status_code == 200 16 | content = response.json() 17 | assert content["title"] == data["title"] 18 | assert content["description"] == data["description"] 19 | assert "id" in content 20 | assert "owner_id" in content 21 | 22 | 23 | def test_read_item( 24 | client: TestClient, superuser_token_headers: dict, db: Session 25 | ) -> None: 26 | item = create_random_item(db) 27 | response = client.get( 28 | f"{settings.API_V1_STR}/items/{item.id}", headers=superuser_token_headers, 29 | ) 30 | assert response.status_code == 200 31 | content = response.json() 32 | assert content["title"] == item.title 33 | assert content["description"] == item.description 34 | assert content["id"] == item.id 35 | assert content["owner_id"] == item.owner_id 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.core.config import settings 6 | 7 | 8 | def test_get_access_token(client: TestClient) -> None: 9 | login_data = { 10 | "username": settings.FIRST_SUPERUSER, 11 | "password": settings.FIRST_SUPERUSER_PASSWORD, 12 | } 13 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 14 | tokens = r.json() 15 | assert r.status_code == 200 16 | assert "access_token" in tokens 17 | assert tokens["access_token"] 18 | 19 | 20 | def test_use_access_token( 21 | client: TestClient, superuser_token_headers: Dict[str, str] 22 | ) -> None: 23 | r = client.post( 24 | f"{settings.API_V1_STR}/login/test-token", headers=superuser_token_headers, 25 | ) 26 | result = r.json() 27 | assert r.status_code == 200 28 | assert "email" in result 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Generator 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | from sqlalchemy.orm import Session 6 | 7 | from app.core.config import settings 8 | from app.db.session import SessionLocal 9 | from app.main import app 10 | from app.tests.utils.user import authentication_token_from_email 11 | from app.tests.utils.utils import get_superuser_token_headers 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def db() -> Generator: 16 | yield SessionLocal() 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def client() -> Generator: 21 | with TestClient(app) as c: 22 | yield c 23 | 24 | 25 | @pytest.fixture(scope="module") 26 | def superuser_token_headers(client: TestClient) -> Dict[str, str]: 27 | return get_superuser_token_headers(client) 28 | 29 | 30 | @pytest.fixture(scope="module") 31 | def normal_user_token_headers(client: TestClient, db: Session) -> Dict[str, str]: 32 | return authentication_token_from_email( 33 | client=client, email=settings.EMAIL_TEST_USER, db=db 34 | ) 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from app import crud, models 6 | from app.schemas.item import ItemCreate 7 | from app.tests.utils.user import create_random_user 8 | from app.tests.utils.utils import random_lower_string 9 | 10 | 11 | def create_random_item(db: Session, *, owner_id: Optional[int] = None) -> models.Item: 12 | if owner_id is None: 13 | user = create_random_user(db) 14 | owner_id = user.id 15 | title = random_lower_string() 16 | description = random_lower_string() 17 | item_in = ItemCreate(title=title, description=description, id=id) 18 | return crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=owner_id) 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | from sqlalchemy.orm import Session 5 | 6 | from app import crud 7 | from app.core.config import settings 8 | from app.models.user import User 9 | from app.schemas.user import UserCreate, UserUpdate 10 | from app.tests.utils.utils import random_email, random_lower_string 11 | 12 | 13 | def user_authentication_headers( 14 | *, client: TestClient, email: str, password: str 15 | ) -> Dict[str, str]: 16 | data = {"username": email, "password": password} 17 | 18 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data) 19 | response = r.json() 20 | auth_token = response["access_token"] 21 | headers = {"Authorization": f"Bearer {auth_token}"} 22 | return headers 23 | 24 | 25 | def create_random_user(db: Session) -> User: 26 | email = random_email() 27 | password = random_lower_string() 28 | user_in = UserCreate(username=email, email=email, password=password) 29 | user = crud.user.create(db=db, obj_in=user_in) 30 | return user 31 | 32 | 33 | def authentication_token_from_email( 34 | *, client: TestClient, email: str, db: Session 35 | ) -> Dict[str, str]: 36 | """ 37 | Return a valid token for the user with given email. 38 | 39 | If the user doesn't exist it is created first. 40 | """ 41 | password = random_lower_string() 42 | user = crud.user.get_by_email(db, email=email) 43 | if not user: 44 | user_in_create = UserCreate(username=email, email=email, password=password) 45 | user = crud.user.create(db, obj_in=user_in_create) 46 | else: 47 | user_in_update = UserUpdate(password=password) 48 | user = crud.user.update(db, db_obj=user, obj_in=user_in_update) 49 | 50 | return user_authentication_headers(client=client, email=email, password=password) 51 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from typing import Dict 4 | 5 | from fastapi.testclient import TestClient 6 | 7 | from app.core.config import settings 8 | 9 | 10 | def random_lower_string() -> str: 11 | return "".join(random.choices(string.ascii_lowercase, k=32)) 12 | 13 | 14 | def random_email() -> str: 15 | return f"{random_lower_string()}@{random_lower_string()}.com" 16 | 17 | 18 | def get_superuser_token_headers(client: TestClient) -> Dict[str, str]: 19 | login_data = { 20 | "username": settings.FIRST_SUPERUSER, 21 | "password": settings.FIRST_SUPERUSER_PASSWORD, 22 | } 23 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 24 | tokens = r.json() 25 | a_token = tokens["access_token"] 26 | headers = {"Authorization": f"Bearer {a_token}"} 27 | return headers 28 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.session import SessionLocal 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 60 * 5 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | def init() -> None: 21 | try: 22 | # Try to create session to check if DB is awake 23 | db = SessionLocal() 24 | db.execute("SELECT 1") 25 | except Exception as e: 26 | logger.error(e) 27 | raise e 28 | 29 | 30 | def main() -> None: 31 | logger.info("Initializing service") 32 | init() 33 | logger.info("Service finished initializing") 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/worker.py: -------------------------------------------------------------------------------- 1 | from raven import Client 2 | 3 | from app.core.celery_app import celery_app 4 | from app.core.config import settings 5 | 6 | client_sentry = Client(settings.SENTRY_DSN) 7 | 8 | 9 | @celery_app.task(acks_late=True) 10 | def test_celery(word: str) -> str: 11 | return f"test task return {word}" 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy, sqlmypy 3 | ignore_missing_imports = True 4 | disallow_untyped_defs = True 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/prestart.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Let the DB start 4 | python /app/app/backend_pre_start.py 5 | 6 | # Run migrations 7 | alembic upgrade head 8 | 9 | # Create initial data in DB 10 | python /app/app/initial_data.py 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Admin "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | uvicorn = "^0.11.3" 10 | fastapi = "^0.54.1" 11 | python-multipart = "^0.0.5" 12 | email-validator = "^1.0.5" 13 | requests = "^2.23.0" 14 | celery = "^4.4.2" 15 | passlib = {extras = ["bcrypt"], version = "^1.7.2"} 16 | tenacity = "^6.1.0" 17 | pydantic = "^1.4" 18 | emails = "^0.5.15" 19 | raven = "^6.10.0" 20 | gunicorn = "^20.0.4" 21 | jinja2 = "^2.11.2" 22 | psycopg2-binary = "^2.8.5" 23 | alembic = "^1.4.2" 24 | sqlalchemy = "^1.3.16" 25 | pytest = "^5.4.1" 26 | python-jose = {extras = ["cryptography"], version = "^3.1.0"} 27 | 28 | [tool.poetry.dev-dependencies] 29 | mypy = "^0.770" 30 | black = "^19.10b0" 31 | isort = "^4.3.21" 32 | autoflake = "^1.3.1" 33 | flake8 = "^3.7.9" 34 | pytest = "^5.4.1" 35 | sqlalchemy-stubs = "^0.3" 36 | pytest-cov = "^2.8.1" 37 | 38 | [tool.isort] 39 | multi_line_output = 3 40 | include_trailing_comma = true 41 | force_grid_wrap = 0 42 | line_length = 88 43 | [build-system] 44 | requires = ["poetry>=0.12"] 45 | build-backend = "poetry.masonry.api" 46 | 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/format-imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | # Sort imports one per line, so autoflake can remove unused imports 5 | isort --recursive --force-single-line-imports --apply app 6 | sh ./scripts/format.sh 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place app --exclude=__init__.py 5 | black app 6 | isort --recursive --apply app 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | 5 | mypy app 6 | black app --check 7 | isort --recursive --check-only app 8 | flake8 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/test-cov-html.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | bash scripts/test.sh --cov-report=html "${@}" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | pytest --cov=app --cov-report=term-missing app/tests "${@}" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/tests-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | python /app/app/tests_pre_start.py 5 | 6 | bash ./scripts/test.sh "$@" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/worker-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | python /app/app/celeryworker_pre_start.py 5 | 6 | celery worker -A app.worker -l info -Q main-queue -c 1 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/backend.dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:latest 2 | 3 | WORKDIR /app/ 4 | 5 | # Install Poetry 6 | RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \ 7 | cd /usr/local/bin && \ 8 | ln -s /opt/poetry/bin/poetry && \ 9 | poetry config virtualenvs.create false 10 | 11 | # Copy poetry.lock* in case it doesn't exist in the repo 12 | COPY ./app/pyproject.toml ./app/poetry.lock* /app/ 13 | 14 | # Allow installing dev dependencies to run tests 15 | ARG INSTALL_DEV=false 16 | RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi" 17 | 18 | # For development, Jupyter remote kernel, Hydrogen 19 | # Using inside the container: 20 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 21 | ARG INSTALL_JUPYTER=false 22 | RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" 23 | 24 | COPY ./app /app 25 | ENV PYTHONPATH=/app 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | WORKDIR /app/ 4 | 5 | # Install Poetry 6 | RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \ 7 | cd /usr/local/bin && \ 8 | ln -s /opt/poetry/bin/poetry && \ 9 | poetry config virtualenvs.create false 10 | 11 | # Copy poetry.lock* in case it doesn't exist in the repo 12 | COPY ./app/pyproject.toml ./app/poetry.lock* /app/ 13 | 14 | # Allow installing dev dependencies to run tests 15 | ARG INSTALL_DEV=false 16 | RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi" 17 | 18 | # For development, Jupyter remote kernel, Hydrogen 19 | # Using inside the container: 20 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 21 | ARG INSTALL_JUPYTER=false 22 | RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" 23 | 24 | ENV C_FORCE_ROOT=1 25 | 26 | COPY ./app /app 27 | WORKDIR /app 28 | 29 | ENV PYTHONPATH=/app 30 | 31 | COPY ./app/worker-start.sh /worker-start.sh 32 | 33 | RUN chmod +x /worker-start.sh 34 | 35 | CMD ["bash", "/worker-start.sh"] 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/cookiecutter-config-file.yml: -------------------------------------------------------------------------------- 1 | default_context: 2 | project_name: '{{ cookiecutter.project_name }}' 3 | project_slug: '{{ cookiecutter.project_slug }}' 4 | domain_main: '{{ cookiecutter.domain_main }}' 5 | domain_staging: '{{ cookiecutter.domain_staging }}' 6 | docker_swarm_stack_name_main: '{{ cookiecutter.docker_swarm_stack_name_main }}' 7 | docker_swarm_stack_name_staging: '{{ cookiecutter.docker_swarm_stack_name_staging }}' 8 | secret_key: '{{ cookiecutter.secret_key }}' 9 | first_superuser: '{{ cookiecutter.first_superuser }}' 10 | first_superuser_password: '{{ cookiecutter.first_superuser_password }}' 11 | backend_cors_origins: '{{ cookiecutter.backend_cors_origins }}' 12 | smtp_port: '{{ cookiecutter.smtp_port }}' 13 | smtp_host: '{{ cookiecutter.smtp_host }}' 14 | smtp_user: '{{ cookiecutter.smtp_user }}' 15 | smtp_password: '{{ cookiecutter.smtp_password }}' 16 | smtp_emails_from_email: '{{ cookiecutter.smtp_emails_from_email }}' 17 | postgres_password: '{{ cookiecutter.postgres_password }}' 18 | pgadmin_default_user: '{{ cookiecutter.pgadmin_default_user }}' 19 | pgadmin_default_user_password: '{{ cookiecutter.pgadmin_default_user_password }}' 20 | traefik_constraint_tag: '{{ cookiecutter.traefik_constraint_tag }}' 21 | traefik_constraint_tag_staging: '{{ cookiecutter.traefik_constraint_tag_staging }}' 22 | traefik_public_constraint_tag: '{{ cookiecutter.traefik_public_constraint_tag }}' 23 | flower_auth: '{{ cookiecutter.flower_auth }}' 24 | sentry_dsn: '{{ cookiecutter.sentry_dsn }}' 25 | docker_image_prefix: '{{ cookiecutter.docker_image_prefix }}' 26 | docker_image_backend: '{{ cookiecutter.docker_image_backend }}' 27 | docker_image_celeryworker: '{{ cookiecutter.docker_image_celeryworker }}' 28 | docker_image_frontend: '{{ cookiecutter.docker_image_frontend }}' 29 | _copy_without_render: [frontend/src/**/*.html, frontend/src/**/*.js, frontend/src/**/*.jsx, frontend/node_modules/*, backend/app/app/email-templates/**] 30 | _template: ./ 31 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "corejs": 3, 7 | "useBuiltIns": "entry", 8 | "modules": false 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-flow" 13 | ], 14 | "plugins": [ 15 | [ 16 | "babel-plugin-styled-components", 17 | { 18 | "fileName": false 19 | } 20 | ], 21 | "@babel/plugin-proposal-function-bind", 22 | "@babel/plugin-proposal-export-default-from", 23 | "@babel/plugin-proposal-logical-assignment-operators", 24 | [ 25 | "@babel/plugin-proposal-optional-chaining", 26 | { 27 | "loose": false 28 | } 29 | ], 30 | [ 31 | "@babel/plugin-proposal-pipeline-operator", 32 | { 33 | "proposal": "minimal" 34 | } 35 | ], 36 | [ 37 | "@babel/plugin-proposal-nullish-coalescing-operator", 38 | { 39 | "loose": false 40 | } 41 | ], 42 | "@babel/plugin-proposal-do-expressions", 43 | [ 44 | "@babel/plugin-proposal-decorators", 45 | { 46 | "legacy": true 47 | } 48 | ], 49 | "@babel/plugin-proposal-function-sent", 50 | "@babel/plugin-proposal-export-namespace-from", 51 | "@babel/plugin-proposal-numeric-separator", 52 | "@babel/plugin-proposal-throw-expressions", 53 | "@babel/plugin-syntax-dynamic-import", 54 | "@babel/plugin-syntax-import-meta", 55 | [ 56 | "@babel/plugin-proposal-class-properties", 57 | { 58 | "loose": false 59 | } 60 | ], 61 | "@babel/plugin-proposal-json-strings", 62 | "@babel/plugin-transform-runtime", 63 | "react-hot-loader/babel" 64 | ], 65 | "compact": true, 66 | "env": { 67 | "production": { 68 | "plugins": [ 69 | "@babel/plugin-transform-flow-strip-types", 70 | "@babel/plugin-transform-object-assign", 71 | "array-includes" 72 | ] 73 | }, 74 | "test": { 75 | "plugins": ["@babel/transform-modules-commonjs", "dynamic-import-node"], 76 | "sourceMaps": "both" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 4 6 | complex-logic: 7 | config: 8 | threshold: 4 9 | file-lines: 10 | config: 11 | threshold: 500 12 | identical-code: 13 | config: 14 | threshold: 50 15 | method-complexity: 16 | config: 17 | threshold: 20 18 | method-count: 19 | config: 20 | threshold: 20 21 | method-lines: 22 | config: 23 | threshold: 50 24 | similar-code: 25 | config: 26 | threshold: 100 27 | exclude_patterns: 28 | - coverage/**/* 29 | - build/**/* 30 | - cypress/**/* 31 | - documentation/**/* 32 | - node_modules/**/* 33 | - test/**/* 34 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | /src/utils/request-temp.js 4 | 5 | # production 6 | /.vscode 7 | 8 | # misc 9 | .DS_Store 10 | npm-debug.log* 11 | yarn-error.log 12 | 13 | /coverage 14 | .idea 15 | yarn.lock 16 | package-lock.json 17 | *bak 18 | .vscode 19 | 20 | # visual studio code 21 | .history 22 | *.log 23 | 24 | functions/mock 25 | .temp/** 26 | 27 | # umi 28 | .umi 29 | .umi-production 30 | 31 | # screenshot 32 | screenshot 33 | .firebase 34 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.env: -------------------------------------------------------------------------------- 1 | FRONTENT_APP_DOMAIN_DEV=localhost 2 | # FRONTEND_APP_DOMAIN_DEV=local.dockertoolbox.tiangolo.com 3 | # FRONTEND_APP_DOMAIN_DEV=localhost.tiangolo.com 4 | # FRONTEND_APP_DOMAIN_DEV=dev.{{cookiecutter.domain_main}} 5 | FRONTEND_APP_DOMAIN_STAG={{cookiecutter.domain_staging}} 6 | FRONTEND_APP_DOMAIN_PROD={{cookiecutter.domain_main}} 7 | FRONTEND_APP_NAME={{cookiecutter.project_name}} 8 | FRONTEND_APP_ENV=development 9 | # FRONTEND_APP_ENV=staging 10 | # FRONTEND_APP_ENV=production 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/config-chain/test/broken.json 3 | .*/node_modules/cypress/.* 4 | .*/node_modules/enzyme-matchers/.* 5 | .*/node_modules/jest-enzyme/.* 6 | .*/node_modules/npmconf/test/.* 7 | .*/node_modules/stylelint/.* 8 | 9 | [include] 10 | 11 | [libs] 12 | 13 | [options] 14 | module.name_mapper='^actions\/\(.*\)$' -> '/src/actions/\1' 15 | module.name_mapper='^components\/\(.*\)$' -> '/src/components/\1' 16 | module.name_mapper='^config$' -> '/src/config' 17 | module.name_mapper='^constants\/\(.*\)$' -> '/src/constants/\1' 18 | module.name_mapper='^containers\/\(.*\)$' -> '/src/containers/\1' 19 | module.name_mapper='^modules\/\(.*\)$' -> '/src/modules/\1' 20 | module.name_mapper='^reducers\/\(.*\)$' -> '/src/reducers/\1' 21 | module.name_mapper='^routes\/\(.*\)$' -> '/src/routes/\1' 22 | module.name_mapper='^sagas\/\(.*\)$' -> '/src/sagas/\1' 23 | module.name_mapper='^store\/\(.*\)$' -> '/src/store/\1' 24 | module.name_mapper='^utils\/\(.*\)$' -> '/src/utils/\1' 25 | 26 | munge_underscores=true 27 | 28 | suppress_type=$FlowIssue 29 | suppress_type=$FlowFixMe 30 | suppress_type=$FixMe 31 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .tmp 2 | .sass-cache 3 | .publish 4 | coverage/ 5 | build/ 6 | dist/ 7 | reports/ 8 | webpack.stats.json 9 | .DS_Store 10 | node_modules 11 | /dist 12 | 13 | # local env files 14 | .env.local 15 | .env.*.local 16 | 17 | # Log files 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Editor directories and files 23 | .idea 24 | .vscode 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw* 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | node_modules 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "processors": ["stylelint-processor-styled-components"], 3 | "extends": [ 4 | "stylelint-config-standard", 5 | "stylelint-config-styled-components" 6 | ], 7 | "plugins": [ 8 | "stylelint-order" 9 | ], 10 | "rules": { 11 | "at-rule-empty-line-before": [ 12 | "always", 13 | { 14 | "except": ["blockless-after-blockless", "first-nested"], 15 | "ignore": ["after-comment"], 16 | "ignoreAtRules": ["else"] 17 | } 18 | ], 19 | "at-rule-no-unknown": null, 20 | "color-named": "never", 21 | "declaration-colon-newline-after": null, 22 | "declaration-block-no-redundant-longhand-properties": null, 23 | "declaration-empty-line-before": null, 24 | "declaration-property-value-blacklist": { 25 | "/^border/": ["none"] 26 | }, 27 | "function-url-quotes": "always", 28 | "indentation": [2, { "ignore": ["value"] }], 29 | "max-nesting-depth": 5, 30 | "no-duplicate-selectors": true, 31 | "no-missing-end-of-source-newline": true, 32 | "number-max-precision": 4, 33 | "property-no-vendor-prefix": true, 34 | "selector-class-pattern": "^((?:-{1,2}|_{2})?[a-z0-9]+(?:(?:-{1,2}|_{2})[a-z0-9]+)*)(?:-{1,2}|_{2})?$", 35 | "selector-max-compound-selectors": 5, 36 | "selector-max-specificity": ["1,6,4"], 37 | "selector-no-qualifying-type": [true, { "ignore": ["class"] }], 38 | "selector-pseudo-element-colon-notation": "single", 39 | "string-quotes": "single", 40 | "unit-blacklist": [ 41 | ["px", "em"], { 42 | "ignoreProperties": { 43 | "px": ["max-width"] 44 | } 45 | } 46 | ], 47 | "order/order": [ 48 | { "type": "at-rule", "name": "import" }, 49 | "custom-properties", 50 | "dollar-variables", 51 | { "type": "at-rule", "name": "extend" }, 52 | { "type": "at-rule", "name": "include", "hasBlock": false }, 53 | "declarations", 54 | { 55 | "type": "at-rule", 56 | "name": "include", 57 | "hasBlock": true 58 | }, 59 | "rules", 60 | "at-rules" 61 | ], 62 | "order/properties-alphabetical-order": true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 0, "build-stage", based on Node.js, to build and compile the frontend 2 | FROM node:latest as build-stage 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json /app/ 7 | 8 | RUN npm install 9 | 10 | COPY ./ /app/ 11 | 12 | ARG FRONTEND_ENV=production 13 | 14 | ENV FRONTEND_APP_ENV=${FRONTEND_ENV} 15 | 16 | # Comment out the next line to disable tests 17 | RUN npm run test:unit 18 | 19 | RUN npm run build 20 | 21 | 22 | # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx 23 | FROM nginx:latest 24 | 25 | COPY --from=build-stage /app/dist/ /usr/share/nginx/html 26 | 27 | COPY --from=build-stage /nginx.conf /etc/nginx/conf.d/default.conf 28 | COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020, Isak Bosman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | MIT License 12 | 13 | Copyright (c) 2015, Gil Barbara 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Frontend 2 | 3 | ### Provides 4 | 5 | - react ^16.x 6 | - react-router 4.x 7 | - react-helmet 5.x 8 | - styled-components 4.x 9 | - redux 4.x 10 | - redux-saga 0.16.x 11 | - redux-persist 5.x 12 | 13 | ### Development 14 | 15 | - webpack-dev-server 3.x 16 | - react-hot-loader 4.x 17 | - redux-devtools (with browser plugin) 18 | 19 | `npm start` 20 | 21 | ### Building 22 | 23 | - webpack 4.x 24 | - babel 7.x 25 | 26 | `npm run build` 27 | 28 | ### Code Quality 29 | 30 | - eslint 5.x 31 | - stylelint 9.x 32 | 33 | `npm run lint` / `npm run lint:styles` 34 | 35 | ### Unit Testing 36 | 37 | - jest 23.x 38 | - enzyme 3.x 39 | 40 | `npm test` 41 | 42 | ### End 2 End Testing 43 | 44 | - cypress 3.0.x 45 | 46 | `npm run test:e2e` 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React-Redux-Saga", 3 | "name": "React-Redux-Saga Boilerplate", 4 | "background_color": "#333333", 5 | "theme_color": "#333333", 6 | "icons": [ 7 | { 8 | "src": "media/meta-icons/icon-96x96.png", 9 | "sizes": "96x96", 10 | "type": "image/png" 11 | }, 12 | { 13 | "src": "media/meta-icons/icon-144x144.png", 14 | "sizes": "144x144", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "media/meta-icons/icon-192x192.png", 19 | "sizes": "192x192", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "media/meta-icons/icon-512x512.png", 24 | "sizes": "512x512", 25 | "type": "image/png" 26 | } 27 | ], 28 | "start_url": "./?utm_source=web_app", 29 | "display": "fullscreen", 30 | "orientation": "portrait" 31 | } 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/brand/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/brand/icon.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/brand/react-redux-saga.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/brand/react-redux-saga.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/bolt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/check-circle-o.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/check-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/dot-circle-o.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/exclamation-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/times-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/icons/times.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/images/og-image-v1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/images/og-image-v1.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/apple-touch-icon.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/favicon-16x16.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/favicon-32x32.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/favicon.ico -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/icon-144x144.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/icon-192x192.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/icon-512x512.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isakbosman/full-stack-fastapi-react-postgres-boilerplate/98ab83f1058dc5d683f3c82f0b49b54f4e598138/{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/icon-96x96.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/media/meta-icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/robots.txt: -------------------------------------------------------------------------------- 1 | # robotstxt.org/ 2 | 3 | User-agent: * 4 | Disallow: 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/service-worker.js: -------------------------------------------------------------------------------- 1 | console.log('Hello from service-worker.js'); 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/config/paths.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const appDirectory = fs.realpathSync(process.cwd()); 5 | const resolvePath = relativePath => path.resolve(appDirectory, relativePath); 6 | 7 | const moduleFileExtensions = ['js', 'json', 'jsx', 'mjs']; 8 | 9 | const resolveModule = (resolveFn, filePath) => { 10 | const extension = moduleFileExtensions.find(ext => 11 | fs.existsSync(resolveFn(`${filePath}.${ext}`)), 12 | ); 13 | 14 | if (extension) { 15 | return resolveFn(`${filePath}.${extension}`); 16 | } 17 | 18 | return resolveFn(`${filePath}.js`); 19 | }; 20 | 21 | module.exports = { 22 | appPath: resolvePath('.'), 23 | appAssets: resolvePath('assets'), 24 | appBuild: resolvePath('build'), 25 | appHtml: resolvePath('assets/index.html'), 26 | appIndexJs: resolveModule(resolvePath, 'src/index'), 27 | appModernizr: resolvePath('src/vendor/modernizr-custom.js'), 28 | appModernizrrc: resolvePath('src/vendor/modernizrrc.json'), 29 | appPolyfills: resolvePath('src/polyfills'), 30 | appSrc: resolvePath('src'), 31 | config: resolvePath('config'), 32 | dotenv: resolvePath('.env'), 33 | nodeModules: resolvePath('node_modules'), 34 | packageJson: resolvePath('package.json'), 35 | publicPath: resolvePath('/'), 36 | test: resolvePath('test'), 37 | }; 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/config/webpackDevServer.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable func-names, prefer-arrow-callback, no-console */ 2 | const path = require('path'); 3 | const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware'); 4 | const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware'); 5 | const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware'); 6 | 7 | const paths = require('./paths'); 8 | 9 | const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; 10 | const host = process.env.HOST || '0.0.0.0'; 11 | 12 | module.exports = function(proxy, allowedHost) { 13 | // noinspection WebpackConfigHighlighting 14 | return { 15 | clientLogLevel: 'none', 16 | compress: true, 17 | contentBase: paths.appAssets, 18 | disableHostCheck: !proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true', 19 | historyApiFallback: { 20 | disableDotRule: true, 21 | }, 22 | host, 23 | hot: true, 24 | https: protocol === 'https', 25 | noInfo: true, 26 | overlay: false, 27 | proxy, 28 | public: allowedHost, 29 | publicPath: '/', 30 | quiet: false, 31 | stats: { colors: true }, 32 | watchOptions: { 33 | ignored: new RegExp( 34 | `^(?!${path 35 | .normalize(`${paths.appSrc}/`) 36 | .replace(/[\\]+/g, '\\\\')}).+[\\\\/]node_modules[\\\\/]`, 37 | 'g', 38 | ), 39 | }, 40 | watchContentBase: true, 41 | before(app, server) { 42 | // This lets us fetch source contents from webpack for the error overlay 43 | app.use(evalSourceMapMiddleware(server)); 44 | // This lets us open files from the runtime error overlay. 45 | app.use(errorOverlayMiddleware()); 46 | // This service worker file is effectively a 'no-op' that will reset any 47 | // previous service worker registered for the same host:port combination. 48 | // We do this in development to avoid hitting the production cache if 49 | // it used the same host and port. 50 | // https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432 51 | app.use(noopServiceWorkerMiddleware()); 52 | }, 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "integrationFolder": "cypress/e2e", 4 | "viewportHeight": 800, 5 | "viewportWidth": 1280, 6 | "video": false, 7 | "projectId": "up2gfx" 8 | } 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/cypress/e2e/basic.js: -------------------------------------------------------------------------------- 1 | describe('React-Redux-Saga-Boilerplate', () => { 2 | it('should assert that is correct', () => { 3 | cy.visit('http://localhost:3000'); 4 | cy.title().should('include', 'React Redux Saga Boilerplate'); 5 | }); 6 | 7 | it('should be able to start', () => { 8 | cy.findByTestId('Login') 9 | .should('contain', 'Start') 10 | .click(); 11 | }); 12 | 13 | it('should be able to view the private area', () => { 14 | cy.findByTestId('PrivateWrapper') 15 | .should('have.length', 1) 16 | .findByTestId('GitHubGrid') 17 | .should('have.length', 1) 18 | .should('have.attr', 'data-type', 'react'); 19 | 20 | cy.findByTestId('GitHubGrid') 21 | .get('li') 22 | .should('have.length', 30); 23 | }); 24 | 25 | it('should be able to dismiss the alert', () => { 26 | cy.findByTestId('AlertWrapper').should('have.length', 1); 27 | cy.findByTestId('AlertButton').click(); 28 | cy.wait(300) 29 | .queryByTestId('AlertWrapper') 30 | .should('not.exist'); 31 | }); 32 | 33 | it('should be able to toggle the selector', () => { 34 | cy.findByTestId('GitHubSelector') 35 | .find('button:last-child') 36 | .not('[disabled]') 37 | .should('have.text', 'Redux') 38 | .click(); 39 | }); 40 | 41 | it('should render the redux repos ', () => { 42 | cy.findByTestId('GitHubGrid') 43 | .should('have.length', 1) 44 | .should('have.attr', 'data-type', 'redux'); 45 | }); 46 | 47 | it('should be able to logout', () => { 48 | cy.get('[class^=Logout]').click(); 49 | }); 50 | 51 | it('should have redirected to /', () => { 52 | cy.findByTestId('HomeWrapper').should('have.length', 1); 53 | }); 54 | 55 | it('should be able to start again', () => { 56 | cy.findByTestId('Login') 57 | .should('contain', 'Start') 58 | .click(); 59 | 60 | cy.findByTestId('PrivateWrapper').should('have.length', 1); 61 | }); 62 | 63 | it('should be able to logout again', () => { 64 | cy.get('[class^=Logout]').click(); 65 | 66 | cy.findByTestId('HomeWrapper').should('have.length', 1); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | /* eslint-disable no-unused-vars */ 15 | module.exports = (on, config) => { 16 | // `on` is used to hook into various events Cypress emits 17 | // `config` is the resolved Cypress config 18 | }; 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | import '@testing-library/cypress/add-commands'; 17 | import './commands'; 18 | 19 | Cypress.Screenshot.defaults({ 20 | screenshotOnRunFailure: false, 21 | }); 22 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: false, 3 | collectCoverageFrom: ['src/**/*.{js,jsx}', '!src/vendor/*', '!src/serviceWorker.js'], 4 | coverageThreshold: { 5 | global: { 6 | branches: 90, 7 | functions: 90, 8 | lines: 90, 9 | statements: 90, 10 | }, 11 | }, 12 | moduleDirectories: ['node_modules', 'src', './'], 13 | moduleFileExtensions: ['js', 'jsx', 'json'], 14 | moduleNameMapper: { 15 | '^app-store': '<rootDir>/src/store', 16 | '\\.(css|scss)$': '<rootDir>/test/__mocks__/styleMock.js', 17 | '\\.(jpe?g|png|gif|ttf|eot|woff|md)$': '<rootDir>/test/__mocks__/fileMock.js', 18 | '\\.svg$': '<rootDir>/test/__mocks__/svgMock.js', 19 | '^(expose|bundle)': '<rootDir>/test/__mocks__/moduleMock.js', 20 | }, 21 | setupFiles: ['<rootDir>/test/__setup__/setupFiles.js'], 22 | setupFilesAfterEnv: ['<rootDir>/test/__setup__/setupTests.js'], 23 | snapshotSerializers: ['jest-serializer-html'], 24 | testEnvironment: 'jest-environment-jsdom-global', 25 | testEnvironmentOptions: { 26 | resources: 'usable', 27 | }, 28 | testRegex: '/test/.*?\\.(test|spec)\\.js$', 29 | testURL: 'http://localhost:3000', 30 | transform: { 31 | '.*': 'babel-jest', 32 | }, 33 | 34 | verbose: false, 35 | watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], 36 | }; 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/nginx-backend-not-found.conf: -------------------------------------------------------------------------------- 1 | location /api { 2 | return 404; 3 | } 4 | location /docs { 5 | return 404; 6 | } 7 | location /redoc { 8 | return 404; 9 | } 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/actions/app.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * @module Actions/App 4 | * @desc App Actions 5 | */ 6 | 7 | import uid from 'nanoid'; 8 | import { createActions } from 'redux-actions'; 9 | 10 | import { ActionTypes } from 'constants/index'; 11 | 12 | export { goBack, go, push, replace } from 'modules/history'; 13 | 14 | export const { hideAlert, showAlert, switchMenu } = createActions({ 15 | [ActionTypes.SWITCH_MENU]: (query: string) => ({ query }), 16 | [ActionTypes.HIDE_ALERT]: (id: string) => ({ id }), 17 | [ActionTypes.SHOW_ALERT]: (message: string, options: Object) => { 18 | const timeout = options.variant === 'danger' ? 0 : 5; 19 | 20 | return { 21 | id: options.id || uid(), 22 | icon: options.icon, 23 | message, 24 | position: options.position || 'bottom-right', 25 | variant: options.variant || 'dark', 26 | timeout: typeof options.timeout === 'number' ? options.timeout : timeout, 27 | }; 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/actions/github.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * @module Actions/User 4 | * @desc User Actions 5 | */ 6 | import { createActions } from 'redux-actions'; 7 | 8 | import { ActionTypes } from 'constants/index'; 9 | 10 | export const { githubGetRepos: getRepos } = createActions({ 11 | [ActionTypes.GITHUB_GET_REPOS]: (query: string) => ({ query }), 12 | }); 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/actions/index.js: -------------------------------------------------------------------------------- 1 | export * from './app'; 2 | export * from './github'; 3 | export * from './user'; 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/actions/user.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * @module Actions/User 4 | * @desc User Actions 5 | */ 6 | import { createActions } from 'redux-actions'; 7 | 8 | import { ActionTypes } from 'constants/index'; 9 | 10 | export const { userLogin: login, userLogout: logOut } = createActions({ 11 | [ActionTypes.USER_LOGIN]: () => ({}), 12 | [ActionTypes.USER_LOGOUT]: () => ({}), 13 | }); 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/Background.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Background = styled.div` 4 | background: #000 linear-gradient(to bottom, #00657e 0%, #002529 100%) fixed; 5 | color: #fff; 6 | min-height: 100vh; 7 | overflow: hidden; 8 | position: relative; 9 | 10 | &:before { 11 | background: linear-gradient(to bottom, #000, #fff); 12 | bottom: 0; 13 | content: ''; 14 | left: 0; 15 | opacity: 0.4; 16 | position: absolute; 17 | right: 0; 18 | top: 0; 19 | transform: rotate(-20deg) scale(2) translate(0, 45%); 20 | } 21 | `; 22 | 23 | export default Background; 24 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { Container, Flex } from 'styled-minimal'; 5 | 6 | const FooterWrapper = styled.footer` 7 | border-top: 0.1rem solid #ddd; 8 | `; 9 | 10 | const Footer = () => ( 11 | <FooterWrapper> 12 | <Container py={3}> 13 | <Flex justifyContent="space-between"> 14 | <iframe 15 | title="GitHub Stars" 16 | src="https://ghbtns.com/github-btn.html?user=gilbarbara&repo=react-redux-saga-boilerplate&type=star&count=true" 17 | frameBorder="0" 18 | scrolling="0" 19 | width="110px" 20 | height="20px" 21 | /> 22 | <iframe 23 | title="GitHub Follow" 24 | src="https://ghbtns.com/github-btn.html?user=gilbarbara&type=follow&count=true" 25 | frameBorder="0" 26 | scrolling="0" 27 | width="130px" 28 | height="20px" 29 | /> 30 | </Flex> 31 | </Container> 32 | </FooterWrapper> 33 | ); 34 | 35 | export default Footer; 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/GlobalStyles.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createGlobalStyle } from 'styled-components'; 3 | import { appColor } from 'modules/theme'; 4 | 5 | const GlobalStyle = createGlobalStyle` 6 | @import url('https://fonts.googleapis.com/css?family=Lato:400,700'); 7 | 8 | *, 9 | *:before, 10 | *:after { 11 | box-sizing: border-box; 12 | } 13 | 14 | html { 15 | font-size: 62.5%; 16 | -webkit-font-smoothing: antialiased; 17 | height: 100%; 18 | } 19 | 20 | body { 21 | font-family: Lato, sans-serif; 22 | font-size: 16px; /* stylelint-disable unit-blacklist */ 23 | margin: 0; 24 | min-height: 100vh; 25 | padding: 0; 26 | } 27 | 28 | img { 29 | height: auto; 30 | max-width: 100%; 31 | } 32 | 33 | a { 34 | color: ${appColor}; 35 | text-decoration: none; 36 | 37 | &.disabled { 38 | pointer-events: none; 39 | } 40 | } 41 | 42 | button { 43 | appearance: none; 44 | background-color: transparent; 45 | border: 0; 46 | cursor: pointer; 47 | display: inline-block; 48 | font-family: inherit; 49 | line-height: 1; 50 | padding: 0; 51 | } 52 | `; 53 | 54 | export default () => <GlobalStyle />; 55 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { appColor, headerHeight } from 'modules/theme'; 5 | 6 | import { logOut } from 'actions'; 7 | 8 | import { Container, utils } from 'styled-minimal'; 9 | import Icon from 'components/Icon'; 10 | import Logo from 'components/Logo'; 11 | 12 | const { responsive, spacer } = utils; 13 | 14 | const HeaderWrapper = styled.header` 15 | background-color: #113740; 16 | height: ${headerHeight}px; 17 | left: 0; 18 | position: fixed; 19 | right: 0; 20 | top: 0; 21 | z-index: 200; 22 | 23 | &:before { 24 | background-color: ${appColor}; 25 | bottom: 0; 26 | content: ''; 27 | height: 0.2rem; 28 | left: 0; 29 | position: absolute; 30 | right: 0; 31 | } 32 | `; 33 | 34 | const HeaderContainer = styled(Container)` 35 | align-items: center; 36 | display: flex; 37 | flex-wrap: wrap; 38 | height: 100%; 39 | justify-content: space-between; 40 | padding-bottom: ${spacer(2)}; 41 | padding-top: ${spacer(2)}; 42 | `; 43 | 44 | const Logout = styled.button` 45 | align-items: center; 46 | color: #fff; 47 | display: flex; 48 | font-size: 1.3rem; 49 | padding: ${spacer(2)}; 50 | 51 | ${responsive({ lg: 'font-size: 1.6rem;' })}; /* stylelint-disable-line */ 52 | 53 | &.active { 54 | color: #fff; 55 | } 56 | 57 | span { 58 | display: inline-block; 59 | margin-right: 0.4rem; 60 | text-transform: uppercase; 61 | } 62 | `; 63 | 64 | export default class Header extends React.PureComponent { 65 | static propTypes = { 66 | dispatch: PropTypes.func.isRequired, 67 | }; 68 | 69 | handleClickLogout = () => { 70 | const { dispatch } = this.props; 71 | 72 | dispatch(logOut()); 73 | }; 74 | 75 | render() { 76 | return ( 77 | <HeaderWrapper> 78 | <HeaderContainer> 79 | <Logo type="logo" /> 80 | <Logout onClick={this.handleClickLogout}> 81 | <span>logout</span> 82 | <Icon name="sign-out" width={16} /> 83 | </Logout> 84 | </HeaderContainer> 85 | </HeaderWrapper> 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/Icon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import SVG from 'react-inlinesvg'; 4 | import styled from 'styled-components'; 5 | import { utils } from 'styled-minimal'; 6 | 7 | const IconWrapper = styled(SVG)` 8 | display: inline-block; 9 | line-height: 0; 10 | 11 | svg { 12 | height: auto; 13 | max-height: 100%; 14 | width: ${({ width }) => utils.px(width)}; 15 | } 16 | `; 17 | 18 | const Icon = ({ name, ...rest }) => ( 19 | <IconWrapper src={`${process.env.PUBLIC_URL}/media/icons/${name}.svg`} {...rest} /> 20 | ); 21 | 22 | Icon.propTypes = { 23 | name: PropTypes.oneOf([ 24 | 'bell-o', 25 | 'bell', 26 | 'bolt', 27 | 'check-circle-o', 28 | 'check-circle', 29 | 'check', 30 | 'dot-circle-o', 31 | 'exclamation-circle', 32 | 'question-circle-o', 33 | 'question-circle', 34 | 'sign-in', 35 | 'sign-out', 36 | 'times-circle-o', 37 | 'times-circle', 38 | 'times', 39 | ]).isRequired, 40 | width: PropTypes.number, 41 | }; 42 | 43 | Icon.defaultProps = { 44 | width: 20, 45 | }; 46 | 47 | export default Icon; 48 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/Logo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | import { ReactComponent as Icon } from 'assets/media/brand/icon.svg'; 6 | import { ReactComponent as RRS } from 'assets/media/brand/react-redux-saga.svg'; 7 | 8 | export const Wrapper = styled.div` 9 | align-items: flex-start; 10 | display: inline-flex; 11 | font-size: 0; 12 | 13 | svg { 14 | height: 4.2rem; 15 | max-height: 100%; 16 | width: auto; 17 | } 18 | `; 19 | 20 | const Logo = ({ type = 'icon' }) => <Wrapper>{type === 'icon' ? <Icon /> : <RRS />}</Wrapper>; 21 | 22 | Logo.propTypes = { 23 | type: PropTypes.string, 24 | }; 25 | 26 | export default Logo; 27 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/Reload.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { Button, Heading } from 'styled-minimal'; 5 | 6 | export const ReloadWrapper = styled.div` 7 | button { 8 | pointer-events: all; 9 | } 10 | `; 11 | 12 | const Reload = () => ( 13 | <ReloadWrapper> 14 | <Heading as="h6" mb={3}> 15 | There's a new version of this app! 16 | </Heading> 17 | <Button variant="dark" bordered size="sm" onClick={() => window.location.reload()}> 18 | Reload 19 | </Button> 20 | </ReloadWrapper> 21 | ); 22 | 23 | export default Reload; 24 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/RoutePrivate.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | 5 | const RoutePrivate = ({ component: Component, isAuthenticated, to, ...rest }) => ( 6 | <Route 7 | {...rest} 8 | render={props => 9 | isAuthenticated ? ( 10 | <Component {...props} /> 11 | ) : ( 12 | <Redirect 13 | to={{ 14 | pathname: to, 15 | state: { redirect: props.location.pathname, isAuthenticated }, 16 | }} 17 | /> 18 | ) 19 | } 20 | /> 21 | ); 22 | 23 | RoutePrivate.propTypes = { 24 | component: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, 25 | isAuthenticated: PropTypes.bool.isRequired, 26 | location: PropTypes.object, 27 | to: PropTypes.string, 28 | }; 29 | 30 | RoutePrivate.defaultProps = { 31 | to: '/', 32 | }; 33 | 34 | export default RoutePrivate; 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/RoutePublic.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | 5 | const RoutePublic = ({ component: Component, isAuthenticated, to, ...rest }) => ( 6 | <Route 7 | {...rest} 8 | render={props => (isAuthenticated ? <Redirect to={to} /> : <Component {...props} />)} 9 | /> 10 | ); 11 | 12 | RoutePublic.propTypes = { 13 | component: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, 14 | isAuthenticated: PropTypes.bool.isRequired, 15 | to: PropTypes.string, 16 | }; 17 | 18 | RoutePublic.defaultProps = { 19 | to: '/private', 20 | }; 21 | 22 | export default RoutePublic; 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/Transition/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { CSSTransition, TransitionGroup } from 'react-transition-group'; 4 | 5 | import transitions, { classNames } from './transitions'; 6 | 7 | const Transition = ({ children, className, style, transition, ...rest }) => { 8 | const Component = transitions[transition]; 9 | 10 | if (!Component) { 11 | console.error(`Invalid transition: ${transition}`); //eslint-disable-line no-console 12 | return null; 13 | } 14 | 15 | return ( 16 | <TransitionGroup className={className} style={style}> 17 | {React.Children.map(children, child => ( 18 | <CSSTransition classNames={classNames[transition]} {...rest}> 19 | <Component>{child}</Component> 20 | </CSSTransition> 21 | ))} 22 | </TransitionGroup> 23 | ); 24 | }; 25 | 26 | Transition.propTypes = { 27 | appear: PropTypes.bool, 28 | children: PropTypes.node, 29 | className: PropTypes.string, 30 | enter: PropTypes.bool, 31 | exit: PropTypes.bool, 32 | style: PropTypes.object, 33 | timeout: PropTypes.number, 34 | transition: PropTypes.oneOf(Object.keys(transitions)), 35 | }; 36 | 37 | Transition.defaultProps = { 38 | appear: false, 39 | enter: true, 40 | exit: true, 41 | style: null, 42 | timeout: 300, 43 | transition: 'fade', 44 | }; 45 | 46 | export default Transition; 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration 3 | * @module config 4 | */ 5 | 6 | const config = { 7 | name: 'React Redux Saga Boilerplate', 8 | description: 'Boilerplate with React and Redux with Redux Saga', 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/constants/index.js: -------------------------------------------------------------------------------- 1 | import { keyMirror } from 'modules/helpers'; 2 | 3 | /** 4 | * @namespace Constants 5 | * @desc App constants 6 | */ 7 | 8 | /** 9 | * @constant {Object} ActionTypes 10 | * @memberof Constants 11 | */ 12 | export const ActionTypes = keyMirror({ 13 | SWITCH_MENU: undefined, 14 | EXCEPTION: undefined, 15 | USER_LOGIN: undefined, 16 | USER_LOGIN_SUCCESS: undefined, 17 | USER_LOGIN_FAILURE: undefined, 18 | USER_LOGOUT: undefined, 19 | USER_LOGOUT_SUCCESS: undefined, 20 | USER_LOGOUT_FAILURE: undefined, 21 | GITHUB_GET_REPOS: undefined, 22 | GITHUB_GET_REPOS_SUCCESS: undefined, 23 | GITHUB_GET_REPOS_FAILURE: undefined, 24 | SHOW_ALERT: undefined, 25 | HIDE_ALERT: undefined, 26 | }); 27 | 28 | /** 29 | * @constant {Object} STATUS 30 | * @memberof Constants 31 | */ 32 | export const STATUS = { 33 | IDLE: 'idle', 34 | RUNNING: 'running', 35 | READY: 'ready', 36 | SUCCESS: 'success', 37 | ERROR: 'error', 38 | }; 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import { Provider } from 'react-redux'; 5 | import { PersistGate } from 'redux-persist/lib/integration/react'; 6 | import { HelmetProvider } from 'react-helmet-async'; 7 | 8 | import { showAlert } from 'actions'; 9 | import { store, persistor } from 'store/index'; 10 | 11 | import Loader from 'components/Loader'; 12 | import Reload from 'components/Reload'; 13 | 14 | import App from './App'; 15 | import { register } from './serviceWorker'; 16 | 17 | ReactDOM.render( 18 | <Provider store={store}> 19 | <PersistGate loading={<Loader size={100} block />} persistor={persistor}> 20 | <HelmetProvider> 21 | <App /> 22 | </HelmetProvider> 23 | </PersistGate> 24 | </Provider>, 25 | document.getElementById('root'), 26 | ); 27 | 28 | /* istanbul ignore next */ 29 | register({ 30 | onUpdate: () => { 31 | store.dispatch(showAlert(<Reload />, { id: 'sw-update', icon: 'bolt', timeout: 0 })); 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/modules/client.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * Client 4 | * @module Client 5 | */ 6 | 7 | export class ServerError extends Error { 8 | response: Object; 9 | 10 | constructor(message?: string): Error { 11 | super(message); 12 | 13 | Error.captureStackTrace(this, ServerError); 14 | 15 | this.name = 'ServerError'; 16 | 17 | return this; 18 | } 19 | } 20 | 21 | export function parseError(error: string): string { 22 | return error || 'Something went wrong'; 23 | } 24 | 25 | /** 26 | * Fetch data 27 | * 28 | * @param {string} url 29 | * @param {Object} options 30 | * @param {string} [options.method] - Request method ( GET, POST, PUT, ... ). 31 | * @param {string} [options.payload] - Request body. 32 | * @param {Object} [options.headers] 33 | * 34 | * @returns {Promise} 35 | */ 36 | export function request(url: string, options: Object = {}): Promise<*> { 37 | const config = { 38 | method: 'GET', 39 | ...options, 40 | }; 41 | const errors = []; 42 | 43 | if (!url) { 44 | errors.push('url'); 45 | } 46 | 47 | if (!config.payload && (config.method !== 'GET' && config.method !== 'DELETE')) { 48 | errors.push('payload'); 49 | } 50 | 51 | if (errors.length) { 52 | throw new Error(`Error! You must pass \`${errors.join('`, `')}\``); 53 | } 54 | 55 | const headers = { 56 | Accept: 'application/json', 57 | 'Content-Type': 'application/json', 58 | ...config.headers, 59 | }; 60 | 61 | const params: Object = { 62 | headers, 63 | method: config.method, 64 | }; 65 | 66 | if (params.method !== 'GET') { 67 | params.body = JSON.stringify(config.payload); 68 | } 69 | 70 | return fetch(url, params).then(async response => { 71 | const contentType = response.headers.get('content-type'); 72 | 73 | if (response.status > 299) { 74 | const error: Object = new ServerError(response.statusText); 75 | error.status = response.status; 76 | 77 | if (contentType && contentType.includes('application/json')) { 78 | error.response = await response.json(); 79 | } else { 80 | error.response = await response.text(); 81 | } 82 | 83 | throw error; 84 | } else { 85 | if (contentType && contentType.includes('application/json')) { 86 | return response.json(); 87 | } 88 | 89 | return response.text(); 90 | } 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/modules/history.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { createBrowserHistory } from 'history'; 3 | import qs from 'qs'; 4 | 5 | const history = createBrowserHistory(); 6 | 7 | history.location = { 8 | ...history.location, 9 | query: qs.parse(history.location.search.substr(1)), 10 | state: {}, 11 | }; 12 | 13 | /* istanbul ignore next */ 14 | history.listen(() => { 15 | history.location = { 16 | ...history.location, 17 | query: qs.parse(history.location.search.substr(1)), 18 | state: history.location.state || {}, 19 | }; 20 | }); 21 | 22 | const { go, goBack, push, replace } = history; 23 | 24 | export { go, goBack, push, replace }; 25 | export default history; 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/modules/theme.js: -------------------------------------------------------------------------------- 1 | export const headerHeight = 70; 2 | 3 | export const appColor = '#00b4d5'; 4 | 5 | export const easing = 'cubic-bezier(0.35, 0.01, 0.77, 0.34);'; 6 | 7 | export default { 8 | breakpoints: { 9 | xs: 0, 10 | ix: 400, 11 | md: 768, 12 | lg: 1024, 13 | xl: 1280, 14 | xxl: 1920, 15 | }, 16 | palette: { 17 | primary: appColor, 18 | }, 19 | button: { 20 | borderRadius: { 21 | xs: 4, 22 | lg: 28, 23 | xl: 32, 24 | }, 25 | padding: { 26 | lg: [12, 28], 27 | xl: [14, 32], 28 | }, 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/polyfills.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Polyfills 3 | * @desc Add support for older browsers. 4 | */ 5 | import 'core-js/stable'; 6 | import 'regenerator-runtime/runtime'; 7 | import 'react-app-polyfill/ie11'; 8 | import 'classlist-polyfill'; 9 | import 'events-polyfill/src/constructors/Event'; 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/reducers/app.js: -------------------------------------------------------------------------------- 1 | import { REHYDRATE } from 'redux-persist/lib/constants'; 2 | import { handleActions } from 'modules/helpers'; 3 | 4 | import { ActionTypes } from 'constants/index'; 5 | 6 | export const appState = { 7 | alerts: [], 8 | }; 9 | 10 | export default { 11 | app: handleActions( 12 | { 13 | [REHYDRATE]: draft => { 14 | draft.alerts = []; 15 | }, 16 | [ActionTypes.HIDE_ALERT]: (draft, { payload: { id } }) => { 17 | draft.alerts = draft.alerts.filter(d => d.id !== id); 18 | }, 19 | [ActionTypes.SHOW_ALERT]: (draft, { payload }) => { 20 | draft.alerts.push(payload); 21 | }, 22 | }, 23 | appState, 24 | ), 25 | }; 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/reducers/github.js: -------------------------------------------------------------------------------- 1 | import { parseError } from 'modules/client'; 2 | import { handleActions } from 'modules/helpers'; 3 | 4 | import { ActionTypes, STATUS } from 'constants/index'; 5 | 6 | export const githubState = { 7 | repos: { 8 | data: {}, 9 | status: STATUS.IDLE, 10 | message: '', 11 | query: '', 12 | }, 13 | }; 14 | 15 | export default { 16 | github: handleActions( 17 | { 18 | [ActionTypes.GITHUB_GET_REPOS]: (draft, { payload }) => { 19 | draft.repos.data[payload.query] = draft.repos.data[payload.query] 20 | ? draft.repos.data[payload.query] 21 | : []; 22 | draft.repos.message = ''; 23 | draft.repos.query = payload.query; 24 | draft.repos.status = STATUS.RUNNING; 25 | }, 26 | [ActionTypes.GITHUB_GET_REPOS_SUCCESS]: (draft, { payload }) => { 27 | draft.repos.data[draft.repos.query] = payload.data || []; 28 | draft.repos.status = STATUS.SUCCESS; 29 | }, 30 | [ActionTypes.GITHUB_GET_REPOS_FAILURE]: (draft, { payload }) => { 31 | draft.repos.message = parseError(payload.message); 32 | draft.repos.status = STATUS.ERROR; 33 | }, 34 | }, 35 | githubState, 36 | ), 37 | }; 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import github from './github'; 3 | import user from './user'; 4 | 5 | export default { 6 | ...app, 7 | ...github, 8 | ...user, 9 | }; 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/reducers/user.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'modules/helpers'; 2 | 3 | import { STATUS, ActionTypes } from 'constants/index'; 4 | 5 | export const userState = { 6 | isAuthenticated: false, 7 | status: STATUS.IDLE, 8 | }; 9 | 10 | export default { 11 | user: handleActions( 12 | { 13 | [ActionTypes.USER_LOGIN]: draft => { 14 | draft.status = STATUS.RUNNING; 15 | }, 16 | [ActionTypes.USER_LOGIN_SUCCESS]: draft => { 17 | draft.isAuthenticated = true; 18 | draft.status = STATUS.READY; 19 | }, 20 | [ActionTypes.USER_LOGOUT]: draft => { 21 | draft.status = STATUS.RUNNING; 22 | }, 23 | [ActionTypes.USER_LOGOUT_SUCCESS]: draft => { 24 | draft.isAuthenticated = false; 25 | draft.status = STATUS.IDLE; 26 | }, 27 | }, 28 | userState, 29 | ), 30 | }; 31 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/routes/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | 5 | import { Container, Heading } from 'styled-minimal'; 6 | import Background from 'components/Background'; 7 | 8 | const StyledContainer = styled(Container)` 9 | align-items: center; 10 | text-align: center; 11 | 12 | h1, 13 | a { 14 | color: #fff; 15 | line-height: 1; 16 | } 17 | 18 | a { 19 | text-decoration: underline; 20 | } 21 | `; 22 | 23 | const NotFound = () => ( 24 | <Background key="404"> 25 | <StyledContainer layout="fullScreen" verticalPadding> 26 | <Heading fontSize={100}>404</Heading> 27 | <Link to="/"> 28 | <Heading as="h2">go home</Heading> 29 | </Link> 30 | </StyledContainer> 31 | </Background> 32 | ); 33 | 34 | export default NotFound; 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/routes/Private.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import Github from 'components/GitHub'; 5 | 6 | import { Box, Container, Heading, Link, Paragraph, Screen, Text, utils } from 'styled-minimal'; 7 | 8 | const Header = styled.div` 9 | margin-bottom: ${utils.spacer(3)}; 10 | text-align: center; 11 | `; 12 | 13 | const Private = () => ( 14 | <Screen key="Private" data-testid="PrivateWrapper"> 15 | <Container verticalPadding> 16 | <Header> 17 | <Heading>Oh hai!</Heading> 18 | <Paragraph> 19 | You can get this boilerplate{' '} 20 | <Link href="https://github.com/gilbarbara/react-redux-saga-boilerplate/" target="_blank"> 21 | here 22 | </Link> 23 | </Paragraph> 24 | </Header> 25 | <Box textAlign="center" mb={4}> 26 | <Heading as="h5">Here's some GitHub data</Heading> 27 | <Text fontSize={1}> 28 | <i>*Just to have some requests in the sagas...</i> 29 | </Text> 30 | </Box> 31 | <Github /> 32 | </Container> 33 | </Screen> 34 | ); 35 | 36 | export default Private; 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/sagas/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Sagas/App 3 | * @desc App 4 | */ 5 | import { all, put, select, takeLatest } from 'redux-saga/effects'; 6 | 7 | import { ActionTypes } from 'constants/index'; 8 | 9 | /** 10 | * Switch Menu 11 | * 12 | * @param {Object} action 13 | * 14 | */ 15 | export function* switchMenu({ payload }) { 16 | try { 17 | const repos = yield select(state => state.github.repos); 18 | 19 | /* istanbul ignore else */ 20 | if (!repos.data[payload.query] || !repos.data[payload.query].length) { 21 | yield put({ 22 | type: ActionTypes.GITHUB_GET_REPOS, 23 | payload, 24 | }); 25 | } 26 | } catch (err) { 27 | /* istanbul ignore next */ 28 | yield put({ 29 | type: ActionTypes.EXCEPTION, 30 | payload: err, 31 | }); 32 | } 33 | } 34 | 35 | /** 36 | * App Sagas 37 | */ 38 | export default function* root() { 39 | yield all([takeLatest(ActionTypes.SWITCH_MENU, switchMenu)]); 40 | } 41 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/sagas/github.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Sagas/GitHub 3 | * @desc GitHub 4 | */ 5 | 6 | import { all, call, put, takeLatest } from 'redux-saga/effects'; 7 | import { request } from 'modules/client'; 8 | 9 | import { ActionTypes } from 'constants/index'; 10 | 11 | /** 12 | * Get Repos 13 | * 14 | * @param {Object} action 15 | * 16 | */ 17 | export function* getRepos({ payload }) { 18 | try { 19 | const response = yield call( 20 | request, 21 | `https://api.github.com/search/repositories?q=${payload.query}&sort=stars`, 22 | ); 23 | yield put({ 24 | type: ActionTypes.GITHUB_GET_REPOS_SUCCESS, 25 | payload: { data: response.items }, 26 | }); 27 | } catch (err) { 28 | /* istanbul ignore next */ 29 | yield put({ 30 | type: ActionTypes.GITHUB_GET_REPOS_FAILURE, 31 | payload: err, 32 | }); 33 | } 34 | } 35 | 36 | /** 37 | * GitHub Sagas 38 | */ 39 | export default function* root() { 40 | yield all([takeLatest(ActionTypes.GITHUB_GET_REPOS, getRepos)]); 41 | } 42 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/sagas/index.js: -------------------------------------------------------------------------------- 1 | import { all, fork } from 'redux-saga/effects'; 2 | 3 | import app from './app'; 4 | import github from './github'; 5 | import user from './user'; 6 | 7 | /** 8 | * rootSaga 9 | */ 10 | export default function* root() { 11 | yield all([fork(app), fork(github), fork(user)]); 12 | } 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/sagas/user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Sagas/User 3 | * @desc User 4 | */ 5 | 6 | import { all, delay, put, takeLatest } from 'redux-saga/effects'; 7 | 8 | import { ActionTypes } from 'constants/index'; 9 | 10 | /** 11 | * Login 12 | */ 13 | export function* login() { 14 | try { 15 | yield delay(400); 16 | 17 | yield put({ 18 | type: ActionTypes.USER_LOGIN_SUCCESS, 19 | }); 20 | } catch (err) { 21 | /* istanbul ignore next */ 22 | yield put({ 23 | type: ActionTypes.USER_LOGIN_FAILURE, 24 | payload: err, 25 | }); 26 | } 27 | } 28 | 29 | /** 30 | * Logout 31 | */ 32 | export function* logout() { 33 | try { 34 | yield delay(200); 35 | 36 | yield put({ 37 | type: ActionTypes.USER_LOGOUT_SUCCESS, 38 | }); 39 | } catch (err) { 40 | /* istanbul ignore next */ 41 | yield put({ 42 | type: ActionTypes.USER_LOGOUT_FAILURE, 43 | payload: err, 44 | }); 45 | } 46 | } 47 | 48 | /** 49 | * User Sagas 50 | */ 51 | export default function* root() { 52 | yield all([ 53 | takeLatest(ActionTypes.USER_LOGIN, login), 54 | takeLatest(ActionTypes.USER_LOGOUT, logout), 55 | ]); 56 | } 57 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore, compose, combineReducers } from 'redux'; 2 | import { persistStore, persistReducer } from 'redux-persist'; 3 | import storage from 'redux-persist/lib/storage'; 4 | 5 | import rootSaga from 'sagas/index'; 6 | import rootReducer from 'reducers/index'; 7 | 8 | import middleware, { sagaMiddleware } from './middleware'; 9 | 10 | const reducer = persistReducer( 11 | { 12 | key: 'rrsb', // key is required 13 | storage, // storage is now required 14 | whitelist: ['app', 'user'], 15 | }, 16 | combineReducers({ ...rootReducer }), 17 | ); 18 | 19 | const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 20 | 21 | /* istanbul ignore next */ 22 | const configStore = (initialState = {}) => { 23 | const store = createStore(reducer, initialState, composeEnhancer(applyMiddleware(...middleware))); 24 | 25 | sagaMiddleware.run(rootSaga); 26 | 27 | if (module.hot) { 28 | module.hot.accept('reducers', () => { 29 | store.replaceReducer(require('reducers/index').default); 30 | }); 31 | } 32 | 33 | return { 34 | persistor: persistStore(store), 35 | store, 36 | }; 37 | }; 38 | 39 | const { store, persistor } = configStore(); 40 | 41 | global.store = store; 42 | 43 | export { store, persistor }; 44 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/middleware.js: -------------------------------------------------------------------------------- 1 | import createSagaMiddleware from 'redux-saga'; 2 | 3 | export const sagaMiddleware = createSagaMiddleware(); 4 | 5 | const middleware = [sagaMiddleware]; 6 | 7 | /* istanbul ignore next */ 8 | if (process.env.NODE_ENV === 'development') { 9 | const { createLogger } = require('redux-logger'); 10 | const invariant = require('redux-immutable-state-invariant').default; 11 | 12 | middleware.push(invariant()); 13 | middleware.push(createLogger({ collapsed: true })); 14 | } 15 | 16 | export default middleware; 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/vendor/modernizr-custom.js: -------------------------------------------------------------------------------- 1 | import Modernizr from 'modernizr'; 2 | 3 | const BrowserDetect = { 4 | init() { 5 | this.browser = this.searchString(this.dataBrowser) || 'Other'; 6 | this.version = 7 | this.searchVersion(navigator.userAgent) || 8 | this.searchVersion(navigator.appVersion) || 9 | 'Unknown'; 10 | }, 11 | searchString(data) { 12 | for (let i = 0; i < data.length; i++) { 13 | const dataString = data[i].string; 14 | this.versionSearchString = data[i].subString; 15 | 16 | if (dataString.indexOf(data[i].subString) !== -1) { 17 | return data[i].identity; 18 | } 19 | } 20 | 21 | return ''; 22 | }, 23 | searchVersion(dataString) { 24 | const index = dataString.indexOf(this.versionSearchString); 25 | if (index === -1) { 26 | return ''; 27 | } 28 | 29 | const rv = dataString.indexOf('rv:'); 30 | if (this.versionSearchString === 'Trident' && rv !== -1) { 31 | return parseFloat(dataString.substring(rv + 3)); 32 | } 33 | 34 | return parseFloat(dataString.substring(index + this.versionSearchString.length + 1)); 35 | }, 36 | 37 | dataBrowser: [ 38 | { string: navigator.userAgent, subString: 'Edge', identity: 'MS Edge' }, 39 | { string: navigator.userAgent, subString: 'MSIE', identity: 'Explorer' }, 40 | { string: navigator.userAgent, subString: 'Trident', identity: 'Explorer' }, 41 | { string: navigator.userAgent, subString: 'Firefox', identity: 'Firefox' }, 42 | { string: navigator.userAgent, subString: 'Opera', identity: 'Opera' }, 43 | { string: navigator.userAgent, subString: 'OPR', identity: 'Opera' }, 44 | { string: navigator.userAgent, subString: 'Chrome', identity: 'Chrome' }, 45 | { string: navigator.userAgent, subString: 'Safari', identity: 'Safari' }, 46 | ], 47 | }; 48 | 49 | BrowserDetect.init(); 50 | 51 | Modernizr.addTest('ipad', Boolean(navigator.userAgent.match(/iPad/i))); 52 | 53 | Modernizr.addTest('iphone', Boolean(navigator.userAgent.match(/iPhone/i))); 54 | 55 | Modernizr.addTest('ipod', Boolean(navigator.userAgent.match(/iPod/i))); 56 | 57 | Modernizr.addTest('ios', Modernizr.ipad || Modernizr.ipod || Modernizr.iphone); 58 | 59 | Modernizr.addTest('ie', Boolean(BrowserDetect.browser === 'Explorer')); 60 | 61 | require('expose?MobileDetect!mobile-detect'); 62 | require('mobile-detect/mobile-detect-modernizr'); 63 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/vendor/modernizrrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "minify": true, 3 | "options": [ 4 | "setClasses" 5 | ], 6 | "feature-detects": [ 7 | "test/applicationcache", 8 | "test/audio", 9 | "test/canvas", 10 | "test/contenteditable", 11 | "test/contextmenu", 12 | "test/cookies", 13 | "test/css/animations", 14 | "test/css/backdropfilter", 15 | "test/css/backgroundblendmode", 16 | "test/css/backgroundcliptext", 17 | "test/css/backgroundsizecover", 18 | "test/css/borderradius", 19 | "test/css/boxshadow", 20 | "test/css/calc", 21 | "test/css/filters", 22 | "test/css/flexbox", 23 | "test/css/fontface", 24 | "test/css/gradients", 25 | "test/css/mediaqueries", 26 | "test/css/nthchild", 27 | "test/css/opacity", 28 | "test/css/pointerevents", 29 | "test/css/remunit", 30 | "test/css/supports", 31 | "test/css/transforms", 32 | "test/css/transforms3d", 33 | "test/css/transformstylepreserve3d", 34 | "test/css/transitions", 35 | "test/css/vhunit", 36 | "test/css/vmaxunit", 37 | "test/css/vminunit", 38 | "test/css/vwunit", 39 | "test/css/will-change", 40 | "test/dom/classlist", 41 | "test/dom/dataset", 42 | "test/emoji", 43 | "test/event/deviceorientation-motion", 44 | "test/eventlistener", 45 | "test/file/api", 46 | "test/hashchange", 47 | "test/img/sizes", 48 | "test/json", 49 | "test/network/fetch", 50 | "test/postmessage", 51 | "test/queryselector", 52 | "test/script/async", 53 | "test/script/defer", 54 | "test/svg", 55 | "test/svg/asimg", 56 | "test/svg/filters", 57 | "test/svg/inline", 58 | "test/touchevents", 59 | "test/video", 60 | "test/window/matchmedia", 61 | "test/workers/sharedworkers", 62 | "test/workers/webworkers" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "jsdom": false, 4 | "navigate": true, 5 | "mount": false, 6 | "shallow": false, 7 | "render": false 8 | }, 9 | "rules": { 10 | "no-console": 0, 11 | "react/jsx-filename-extension": 0, 12 | "react/prop-types": 0 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/App.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { App } from 'App'; 4 | 5 | const mockDispatch = jest.fn(); 6 | 7 | const props = { 8 | app: { 9 | alerts: [], 10 | }, 11 | dispatch: mockDispatch, 12 | user: { 13 | isAuthenticated: false, 14 | }, 15 | }; 16 | 17 | function setup(ownProps = props) { 18 | return shallow(<App {...ownProps} />, { attachTo: document.getElementById('react') }); 19 | } 20 | 21 | describe('App', () => { 22 | const wrapper = setup(); 23 | 24 | it('should render properly for anonymous users', () => { 25 | expect(wrapper.debug()).toMatchSnapshot(); 26 | }); 27 | 28 | it('should render properly for logged users', () => { 29 | wrapper.setProps({ 30 | ...wrapper.props(), 31 | user: { 32 | isAuthenticated: true, 33 | }, 34 | }); 35 | 36 | expect(wrapper.find('Header')).toExist(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = ''; 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__mocks__/history.js: -------------------------------------------------------------------------------- 1 | export * from 'history/cjs/history'; 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__mocks__/moduleMock.js: -------------------------------------------------------------------------------- 1 | // Return an object to mock a module 2 | module.exports = () => {}; 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // Return an object to emulate css modules (if you are using them) 2 | module.exports = {}; 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__mocks__/svgMock.js: -------------------------------------------------------------------------------- 1 | module.exports = { ReactComponent: () => 'SVG' }; 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__setup__/setupFiles.js: -------------------------------------------------------------------------------- 1 | import Enzyme, { shallow, mount, render } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | import 'polyfills'; 5 | 6 | Enzyme.configure({ adapter: new Adapter() }); 7 | 8 | process.env.PUBLIC_URL = ''; 9 | 10 | const root = document.createElement('div'); 11 | root.id = 'root'; 12 | root.style.height = '100vh'; 13 | document.body.appendChild(root); 14 | 15 | process.on('uncaughtException', err => { 16 | console.error(`${new Date().toUTCString()} uncaughtException:`, err.message); 17 | console.error(err.stack); 18 | }); 19 | 20 | global.navigate = options => { 21 | const { pathname = location.pathname, search, hash } = options; 22 | let url = `${location.protocol}//${location.host}${pathname}`; 23 | 24 | if (search) { 25 | url += `?${search}`; 26 | } 27 | 28 | if (hash) { 29 | url += `#${hash}`; 30 | } 31 | 32 | jsdom.reconfigure({ url }); 33 | }; 34 | 35 | global.shallow = shallow; 36 | global.mount = mount; 37 | global.render = render; 38 | 39 | global.fetch = require('jest-fetch-mock'); 40 | 41 | global.fetchInit = { 42 | headers: { 43 | 'content-type': 'application/json', 44 | }, 45 | }; 46 | 47 | global.requestAnimationFrame = callback => { 48 | setTimeout(callback, 0); 49 | }; 50 | 51 | global.matchMedia = () => ({ 52 | matches: false, 53 | addListener: () => {}, 54 | removeListener: () => {}, 55 | }); 56 | 57 | const consoleError = console.error; 58 | console.error = jest.fn(error => { 59 | const message = error instanceof Error ? error.message : error; 60 | const skipMessages = [ 61 | 'Warning: <%s /> is using incorrect casing.', 62 | 'The tag <%s> is unrecognized in this browser.', 63 | // 'Warning: Failed prop type', 64 | '`transition` of value `rotate`', 65 | 'Invalid transition: rotate', 66 | "Content type isn't valid:", 67 | ]; 68 | let shouldSkip = false; 69 | 70 | for (const s of skipMessages) { 71 | if (message.includes(s)) { 72 | shouldSkip = true; 73 | } 74 | } 75 | 76 | if (!shouldSkip) { 77 | consoleError(error); 78 | } 79 | }); 80 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__setup__/setupTests.js: -------------------------------------------------------------------------------- 1 | import 'jest-localstorage-mock'; 2 | import 'jest-enzyme'; 3 | import 'jest-extended'; 4 | import 'jest-chain'; 5 | import 'jest-styled-components'; 6 | import { createSerializer } from 'enzyme-to-json'; 7 | 8 | expect.addSnapshotSerializer(createSerializer({ mode: 'deep' })); 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__setup__/withContext.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/destructuring-assignment, prefer-destructuring */ 2 | import PropTypes from 'prop-types'; 3 | 4 | // shallow() with React Intl context 5 | global.shallowWithContext = ( 6 | node, 7 | { context, ...options } = {}, 8 | { mockDispatch, actions } = {}, 9 | ) => { 10 | let store; 11 | 12 | if ((context && !context.store) || !context) { 13 | store = require.requireActual('store').store; 14 | 15 | if (actions) { 16 | actions.map(d => store.dispatch(d)); 17 | } 18 | 19 | if (mockDispatch) { 20 | const storeDispatch = store.dispatch; 21 | mockDispatch.dispatch = action => mockDispatch(action, storeDispatch); 22 | 23 | store.dispatch = action => mockDispatch(action, storeDispatch); 24 | } 25 | } 26 | 27 | return shallow(node, { 28 | ...options, 29 | context: { 30 | store, 31 | ...context, 32 | }, 33 | }); 34 | }; 35 | 36 | // mount() with React Intl context 37 | global.mountWithContext = ( 38 | node, 39 | { context, childContextTypes, ...options } = {}, 40 | { mockDispatch, actions } = {}, 41 | ) => { 42 | let store; 43 | 44 | if ((context && !context.store) || !context) { 45 | store = require.requireActual('store').store; 46 | 47 | if (actions) { 48 | actions.map(d => store.dispatch(d)); 49 | } 50 | 51 | if (mockDispatch) { 52 | const storeDispatch = store.dispatch; 53 | mockDispatch.dispatch = action => mockDispatch(action, storeDispatch); 54 | 55 | store.dispatch = action => mockDispatch(action, storeDispatch); 56 | } 57 | } 58 | 59 | return mount(node, { 60 | ...options, 61 | context: { 62 | store, 63 | ...context, 64 | }, 65 | childContextTypes: { 66 | ...childContextTypes, 67 | store: PropTypes.object, 68 | }, 69 | }); 70 | }; 71 | 72 | global.setMockDispatch = () => 73 | jest.fn((action, dispatch) => { 74 | const fn = dispatch || this.dispatch; 75 | 76 | return fn(action); 77 | }); 78 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/__snapshots__/App.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App should render properly for anonymous users 1`] = ` 4 | <Router history="{{...}}"> 5 | <ThemeProvider theme="{{...}}"> 6 | <AppWrapper logged="{false}"> 7 | <Helmet defer="{false}" 8 | htmlattributes="{{...}}" 9 | encodespecialcharacters="{true}" 10 | defaulttitle="React Redux Saga Boilerplate" 11 | titletemplate="%s | React Redux Saga Boilerplate" 12 | titleattributes="{{...}}" 13 | > 14 | </Helmet> 15 | <Main isauthenticated="{false}"> 16 | <Switch> 17 | <RoutePublic isauthenticated="{false}" 18 | path="/" 19 | exact="{true}" 20 | component="{{...}}" 21 | to="/private" 22 | > 23 | </RoutePublic> 24 | <RoutePrivate isauthenticated="{false}" 25 | path="/private" 26 | component="{[Function:" 27 | private]} 28 | to="/" 29 | > 30 | </RoutePrivate> 31 | <Route component="{[Function:" 32 | notfound]} 33 | > 34 | </Route> 35 | </Switch> 36 | </Main> 37 | <Footer> 38 | </Footer> 39 | <Connect(SystemAlerts)> 40 | </Connect(SystemAlerts)> 41 | <_default> 42 | </_default> 43 | </AppWrapper> 44 | </ThemeProvider> 45 | </Router> 46 | `; 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/actions/__snapshots__/app.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App hideAlert should return an action 1`] = ` 4 | Object { 5 | "payload": Object { 6 | "id": "test", 7 | }, 8 | "type": "HIDE_ALERT", 9 | } 10 | `; 11 | 12 | exports[`App showAlert with variant \`error\` should return an action 1`] = ` 13 | Object { 14 | "payload": Object { 15 | "icon": undefined, 16 | "id": "test", 17 | "message": "Alright!", 18 | "position": "bottom-right", 19 | "timeout": 0, 20 | "variant": "danger", 21 | }, 22 | "type": "SHOW_ALERT", 23 | } 24 | `; 25 | 26 | exports[`App showAlert with variant \`success\` should return an action 1`] = ` 27 | Object { 28 | "payload": Object { 29 | "icon": undefined, 30 | "id": "test", 31 | "message": "Alright!", 32 | "position": "bottom-right", 33 | "timeout": 10, 34 | "variant": "success", 35 | }, 36 | "type": "SHOW_ALERT", 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/actions/__snapshots__/user.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App logOut should return an action 1`] = ` 4 | Object { 5 | "payload": Object {}, 6 | "type": "USER_LOGOUT", 7 | } 8 | `; 9 | 10 | exports[`App login should return an action 1`] = ` 11 | Object { 12 | "payload": Object {}, 13 | "type": "USER_LOGIN", 14 | } 15 | `; 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/actions/app.spec.js: -------------------------------------------------------------------------------- 1 | import { hideAlert, showAlert } from 'actions/app'; 2 | 3 | describe('App', () => { 4 | it('showAlert with variant `error` should return an action', () => { 5 | expect(showAlert('Alright!', { id: 'test', variant: 'danger' })).toMatchSnapshot(); 6 | }); 7 | 8 | it('showAlert with variant `success` should return an action', () => { 9 | expect( 10 | showAlert('Alright!', { id: 'test', variant: 'success', timeout: 10 }), 11 | ).toMatchSnapshot(); 12 | }); 13 | 14 | it('hideAlert should return an action', () => { 15 | expect(hideAlert('test')).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/actions/user.spec.js: -------------------------------------------------------------------------------- 1 | import { login, logOut } from 'actions/user'; 2 | 3 | describe('App', () => { 4 | it('login should return an action', () => { 5 | expect(login()).toMatchSnapshot(); 6 | }); 7 | 8 | it('logOut should return an action', () => { 9 | expect(logOut()).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Alert.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Alert from 'components/Alert'; 4 | 5 | function setup(children = 'Hello World', variant) { 6 | return mount(<Alert variant={variant}>{children}</Alert>); 7 | } 8 | 9 | describe('Alert', () => { 10 | let wrapper = setup(); 11 | 12 | it('should render properly', () => { 13 | expect(wrapper).toMatchSnapshot(); 14 | }); 15 | 16 | it('should render a success alert', () => { 17 | wrapper = setup('This is a success message', 'success'); 18 | 19 | expect(wrapper).toMatchSnapshot(); 20 | }); 21 | 22 | it('should render a error alert with markup', () => { 23 | wrapper = setup(<p>This is an error message</p>, 'danger'); 24 | 25 | expect(wrapper).toMatchSnapshot(); 26 | }); 27 | 28 | it('should render a warning alert', () => { 29 | wrapper = setup('This is a warning message', 'warning'); 30 | 31 | expect(wrapper).toMatchSnapshot(); 32 | }); 33 | 34 | it('should render a info alert', () => { 35 | wrapper = setup('This is an info message', 'info'); 36 | 37 | expect(wrapper).toMatchSnapshot(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Background.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Background from 'components/Background'; 4 | 5 | describe('Background', () => { 6 | const wrapper = mount(<Background />); 7 | 8 | it('should render properly', () => { 9 | expect(wrapper).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Footer.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Footer from 'components/Footer'; 4 | 5 | function setup() { 6 | return mount(<Footer />); 7 | } 8 | 9 | describe('Footer', () => { 10 | const wrapper = setup(); 11 | 12 | it('should render properly', () => { 13 | expect(wrapper).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/GlobalStyles.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import GlobalStyles from 'components/GlobalStyles'; 4 | 5 | describe('GlobalStyles', () => { 6 | const wrapper = mount(<GlobalStyles />); 7 | 8 | it('should render properly', () => { 9 | expect(document.head.querySelector('[data-styled]')).not.toBeNull(); 10 | expect(wrapper.find('GlobalStyleComponent').instance().state.globalStyle).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Header.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Header from 'components/Header'; 4 | 5 | const mockDispatch = jest.fn(); 6 | 7 | function setup() { 8 | const props = { 9 | app: {}, 10 | dispatch: mockDispatch, 11 | location: { 12 | pathname: '/', 13 | }, 14 | }; 15 | 16 | return mount(<Header suppressClassNameWarning {...props} />); 17 | } 18 | 19 | describe('Header', () => { 20 | const wrapper = setup(); 21 | 22 | it('should render properly', () => { 23 | expect(wrapper).toMatchSnapshot(); 24 | }); 25 | 26 | it('should handle clicks', () => { 27 | wrapper.find('Logout').simulate('click'); 28 | 29 | expect(mockDispatch).toHaveBeenCalledWith({ 30 | type: 'USER_LOGOUT', 31 | payload: {}, 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Icon.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Icon from 'components/Icon'; 4 | 5 | describe('Icon', () => { 6 | const wrapper = mount(<Icon name="check" />); 7 | 8 | it('should render properly', () => { 9 | expect(wrapper).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Loader.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Loader from 'components/Loader'; 4 | 5 | describe('Loader', () => { 6 | let wrapper; 7 | 8 | describe('with type `grow` (default)', () => { 9 | it('should render properly', () => { 10 | wrapper = mount(<Loader />); 11 | expect(wrapper).toMatchSnapshot(); 12 | }); 13 | 14 | it('should render properly with options', () => { 15 | wrapper = mount(<Loader block size="10rem" />); 16 | expect(wrapper).toMatchSnapshot(); 17 | }); 18 | }); 19 | 20 | describe('with type `pulse`', () => { 21 | it('should render properly', () => { 22 | wrapper = mount(<Loader type="pulse" />); 23 | expect(wrapper).toMatchSnapshot(); 24 | }); 25 | 26 | it('should render properly with options', () => { 27 | wrapper = mount(<Loader block type="pulse" size="10rem" />); 28 | expect(wrapper).toMatchSnapshot(); 29 | }); 30 | }); 31 | describe('with type `rotate`', () => { 32 | it('should render properly', () => { 33 | wrapper = mount(<Loader type="rotate" />); 34 | expect(wrapper).toMatchSnapshot(); 35 | }); 36 | 37 | it('should render properly with options', () => { 38 | wrapper = mount(<Loader block type="rotate" size="10rem" />); 39 | expect(wrapper).toMatchSnapshot(); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Logo.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Logo from 'components/Logo'; 4 | 5 | describe('Logo', () => { 6 | const wrapper = mount(<Logo />); 7 | 8 | it('should render properly', () => { 9 | expect(wrapper).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Reload.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Reload from 'components/Reload'; 4 | 5 | describe('Reload', () => { 6 | const wrapper = mount(<Reload />); 7 | 8 | it('should render properly', () => { 9 | expect(wrapper).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/RoutePrivate.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MemoryRouter as Router } from 'react-router-dom'; 3 | import { renderToString } from 'react-dom/server'; 4 | import RoutePrivate from 'components/RoutePrivate'; 5 | 6 | describe('RoutePrivate', () => { 7 | it('should redirect for unauthenticated access', () => { 8 | const render = renderToString( 9 | <Router initialEntries={['/private']}> 10 | <RoutePrivate 11 | exact 12 | path="/private" 13 | component={() => <div>PRIVATE</div>} 14 | isAuthenticated={false} 15 | /> 16 | </Router>, 17 | ); 18 | 19 | expect(render).toMatchSnapshot(); 20 | }); 21 | 22 | it('should allow navigation for authenticated access', () => { 23 | const render = renderToString( 24 | <Router initialEntries={['/private']}> 25 | <RoutePrivate 26 | exact 27 | path="/private" 28 | component={() => <div>PRIVATE</div>} 29 | isAuthenticated={true} 30 | /> 31 | </Router>, 32 | ); 33 | 34 | expect(render).toMatchSnapshot(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/RoutePublic.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MemoryRouter as Router } from 'react-router-dom'; 3 | import { renderToString } from 'react-dom/server'; 4 | import RoutePublic from 'components/RoutePublic'; 5 | 6 | describe('RoutePublic', () => { 7 | it('should render the Login component for unauthenticated access', () => { 8 | const render = renderToString( 9 | <Router initialEntries={['/login']}> 10 | <RoutePublic 11 | exact 12 | path="/login" 13 | component={() => <div>LOGIN</div>} 14 | isAuthenticated={false} 15 | /> 16 | </Router>, 17 | ); 18 | 19 | expect(render).toMatchSnapshot(); 20 | }); 21 | 22 | it('should redirect to /private for authenticated access', () => { 23 | const render = renderToString( 24 | <Router initialEntries={['/login']}> 25 | <RoutePublic 26 | exact 27 | path="/login" 28 | component={() => <div>LOGIN</div>} 29 | isAuthenticated={true} 30 | /> 31 | </Router>, 32 | ); 33 | 34 | expect(render).toMatchSnapshot(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Transition/__snapshots__/index.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Transition should not render if transition don't exist 1`] = `""`; 4 | 5 | exports[`Transition should render properly 1`] = ` 6 | .c0.fade-appear, 7 | .c0.fade-enter { 8 | opacity: 0.01; 9 | } 10 | 11 | .c0.fade-appear.fade-appear-active { 12 | opacity: 1; 13 | -webkit-transition: 0.3s opacity; 14 | transition: 0.3s opacity; 15 | } 16 | 17 | .c0.fade-enter.fade-enter-active { 18 | opacity: 1; 19 | -webkit-transition: 0.3s opacity; 20 | transition: 0.3s opacity; 21 | } 22 | 23 | .c0.fade-exit { 24 | opacity: 1; 25 | } 26 | 27 | .c0.fade-exit.fade-exit-active { 28 | opacity: 0.01; 29 | -webkit-transition: 0.3s opacity; 30 | transition: 0.3s opacity; 31 | } 32 | 33 | <div 34 | style={null} 35 | > 36 | <Fade> 37 | <div 38 | className="c0" 39 | > 40 | <div 41 | className="transition" 42 | /> 43 | </div> 44 | </Fade> 45 | </div> 46 | `; 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/Transition/index.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Transition from 'components/Transition/index'; 4 | 5 | describe('Transition', () => { 6 | it('should render properly', () => { 7 | const wrapper = mount( 8 | <Transition transition="fade"> 9 | <div className="transition" /> 10 | </Transition>, 11 | ); 12 | 13 | expect(wrapper).toMatchSnapshot(); 14 | }); 15 | 16 | it("should not render if transition don't exist", () => { 17 | const wrapper = mount( 18 | <Transition transition="rotate"> 19 | <div className="transition" /> 20 | </Transition>, 21 | ); 22 | 23 | expect(wrapper).toMatchSnapshot(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/Background.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Background should render properly 1`] = ` 4 | .c0 { 5 | background: #000 linear-gradient(to bottom,#00657e 0%,#002529 100%) fixed; 6 | color: #fff; 7 | min-height: 100vh; 8 | overflow: hidden; 9 | position: relative; 10 | } 11 | 12 | .c0:before { 13 | background: linear-gradient(to bottom,#000,#fff); 14 | bottom: 0; 15 | content: ''; 16 | left: 0; 17 | opacity: 0.4; 18 | position: absolute; 19 | right: 0; 20 | top: 0; 21 | -webkit-transform: rotate(-20deg) scale(2) translate(0,45%); 22 | -ms-transform: rotate(-20deg) scale(2) translate(0,45%); 23 | transform: rotate(-20deg) scale(2) translate(0,45%); 24 | } 25 | 26 | <Background> 27 | <div 28 | className="c0" 29 | /> 30 | </Background> 31 | `; 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/Footer.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Footer should render properly 1`] = ` 4 | .c1 { 5 | box-sizing: border-box; 6 | padding-top: 16px; 7 | padding-bottom: 16px; 8 | margin-left: auto; 9 | margin-right: auto; 10 | padding-left: 16px; 11 | padding-right: 16px; 12 | max-width: 1440px; 13 | position: relative; 14 | width: 100%; 15 | } 16 | 17 | .c2 { 18 | box-sizing: border-box; 19 | -webkit-align-items: center; 20 | -webkit-box-align: center; 21 | -ms-flex-align: center; 22 | align-items: center; 23 | -webkit-box-pack: justify; 24 | -webkit-justify-content: space-between; 25 | -ms-flex-pack: justify; 26 | justify-content: space-between; 27 | display: -webkit-box; 28 | display: -webkit-flex; 29 | display: -ms-flexbox; 30 | display: flex; 31 | } 32 | 33 | .c0 { 34 | border-top: 0.1rem solid #ddd; 35 | } 36 | 37 | @media (min-width:768px) { 38 | .c1 { 39 | padding-left: 32px; 40 | padding-right: 32px; 41 | } 42 | } 43 | 44 | <FooterWrapper> 45 | <footer 46 | className="c0" 47 | > 48 | <Container 49 | py={3} 50 | verticalPadding={false} 51 | > 52 | <div 53 | className="c1" 54 | > 55 | <Flex 56 | alignItems="center" 57 | justifyContent="space-between" 58 | > 59 | <div 60 | className="c2" 61 | > 62 | <iframe 63 | frameBorder="0" 64 | height="20px" 65 | scrolling="0" 66 | src="https://ghbtns.com/github-btn.html?user=gilbarbara&repo=react-redux-saga-boilerplate&type=star&count=true" 67 | title="GitHub Stars" 68 | width="110px" 69 | /> 70 | <iframe 71 | frameBorder="0" 72 | height="20px" 73 | scrolling="0" 74 | src="https://ghbtns.com/github-btn.html?user=gilbarbara&type=follow&count=true" 75 | title="GitHub Follow" 76 | width="130px" 77 | /> 78 | </div> 79 | </Flex> 80 | </div> 81 | </Container> 82 | </footer> 83 | </FooterWrapper> 84 | `; 85 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/GitHub.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GitHub should render properly 1`] = ` 4 | <div data-testid="GitHubWrapper"> 5 | <div class="Box-sc-17ftkho-0 sc-bxivhb fqsBjL"> 6 | <div role="group" 7 | aria-label="GitHub Selector" 8 | data-testid="GitHubSelector" 9 | class="Box-sc-17ftkho-0 StyledButtonGroup-fvsrxr-0 bkUYrT" 10 | > 11 | <button data-query="react" 12 | type="button" 13 | class="Box-sc-17ftkho-0 Button-sc-163ts80-0 gvDtBj" 14 | > 15 | React 16 | </button> 17 | <button data-query="redux" 18 | type="button" 19 | class="Box-sc-17ftkho-0 Button-sc-163ts80-0 chQgvY" 20 | > 21 | Redux 22 | </button> 23 | </div> 24 | </div> 25 | <div color="#00b4d5" 26 | size="32" 27 | type="grow" 28 | class="LoaderGrow-sc-19vb2rk-0 iLaFLl" 29 | > 30 | <div> 31 | </div> 32 | </div> 33 | </div> 34 | `; 35 | 36 | exports[`GitHub should render the Grid if data exists 1`] = `null`; 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/GlobalStyles.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GlobalStyles should render properly 1`] = ` 4 | GlobalStyle { 5 | "componentId": "sc-global-3983640001", 6 | "isStatic": true, 7 | "rules": Array [ 8 | " 9 | @import url('https://fonts.googleapis.com/css?family=Lato:400,700'); 10 | 11 | *, 12 | *:before, 13 | *:after { 14 | box-sizing: border-box; 15 | } 16 | 17 | html { 18 | font-size: 62.5%; 19 | -webkit-font-smoothing: antialiased; 20 | height: 100%; 21 | } 22 | 23 | body { 24 | font-family: Lato, sans-serif; 25 | font-size: 16px; /* stylelint-disable unit-blacklist */ 26 | margin: 0; 27 | min-height: 100vh; 28 | padding: 0; 29 | } 30 | 31 | img { 32 | height: auto; 33 | max-width: 100%; 34 | } 35 | 36 | a { 37 | color: ", 38 | "#00b4d5", 39 | "; 40 | text-decoration: none; 41 | 42 | &.disabled { 43 | pointer-events: none; 44 | } 45 | } 46 | 47 | button { 48 | appearance: none; 49 | background-color: transparent; 50 | border: 0; 51 | cursor: pointer; 52 | display: inline-block; 53 | font-family: inherit; 54 | line-height: 1; 55 | padding: 0; 56 | } 57 | ", 58 | ], 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/Icon.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Icon should render properly 1`] = ` 4 | <IconWrapper 5 | src="/media/icons/check.svg" 6 | width={20} 7 | /> 8 | `; 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/Logo.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Logo should render properly 1`] = ` 4 | .c0 { 5 | -webkit-align-items: flex-start; 6 | -webkit-box-align: flex-start; 7 | -ms-flex-align: flex-start; 8 | align-items: flex-start; 9 | display: -webkit-inline-box; 10 | display: -webkit-inline-flex; 11 | display: -ms-inline-flexbox; 12 | display: inline-flex; 13 | font-size: 0; 14 | } 15 | 16 | .c0 svg { 17 | height: 4.2rem; 18 | max-height: 100%; 19 | width: auto; 20 | } 21 | 22 | <Wrapper> 23 | <div 24 | className="c0" 25 | > 26 | SVG 27 | </div> 28 | </Wrapper> 29 | `; 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/Reload.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Reload should render properly 1`] = ` 4 | .c2 { 5 | box-sizing: border-box; 6 | background-color: #fff; 7 | border: 1px solid #212121; 8 | color: #212121; 9 | -webkit-align-items: center; 10 | -webkit-box-align: center; 11 | -ms-flex-align: center; 12 | align-items: center; 13 | border-radius: 0px; 14 | box-shadow: none; 15 | cursor: pointer; 16 | display: -webkit-inline-box; 17 | display: -webkit-inline-flex; 18 | display: -ms-inline-flexbox; 19 | display: inline-flex; 20 | font-family: inherit; 21 | font-size: 14px; 22 | -webkit-box-pack: center; 23 | -webkit-justify-content: center; 24 | -ms-flex-pack: center; 25 | justify-content: center; 26 | line-height: 1; 27 | padding: 7px 14px; 28 | -webkit-text-decoration: none; 29 | text-decoration: none; 30 | width: auto; 31 | } 32 | 33 | .c2:disabled { 34 | pointer-events: none; 35 | opacity: 0.7; 36 | } 37 | 38 | .c2:focus { 39 | outline-color: #212121; 40 | } 41 | 42 | .c2 .c3 { 43 | margin-left: 5px; 44 | } 45 | 46 | .c1 { 47 | box-sizing: border-box; 48 | margin-bottom: 16px; 49 | font-size: 16px; 50 | font-family: inherit; 51 | font-weight: 700; 52 | line-height: 1.4; 53 | margin-bottom: 3px; 54 | margin-top: 16px; 55 | } 56 | 57 | .c1:first-child { 58 | margin-top: 0; 59 | } 60 | 61 | .c0 button { 62 | pointer-events: all; 63 | } 64 | 65 | <ReloadWrapper> 66 | <div 67 | className="c0" 68 | > 69 | <Heading 70 | as="h6" 71 | mb={3} 72 | > 73 | <h6 74 | className="c1" 75 | > 76 | There's a new version of this app! 77 | </h6> 78 | </Heading> 79 | <Button 80 | animate={false} 81 | as="button" 82 | block={false} 83 | bordered={true} 84 | dark={false} 85 | disabled={false} 86 | onClick={[Function]} 87 | size="sm" 88 | type="button" 89 | variant="dark" 90 | > 91 | <button 92 | className="c2" 93 | disabled={false} 94 | onClick={[Function]} 95 | size="sm" 96 | type="button" 97 | > 98 | Reload 99 | </button> 100 | </Button> 101 | </div> 102 | </ReloadWrapper> 103 | `; 104 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/RoutePrivate.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`RoutePrivate should allow navigation for authenticated access 1`] = ` 4 | <div> 5 | PRIVATE 6 | </div> 7 | `; 8 | 9 | exports[`RoutePrivate should redirect for unauthenticated access 1`] = ` 10 | <div> 11 | REDIRECT 12 | </div> 13 | `; 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/RoutePublic.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`RoutePublic should redirect to /private for authenticated access 1`] = ` 4 | <div> 5 | REDIRECT 6 | </div> 7 | `; 8 | 9 | exports[`RoutePublic should render the Login component for unauthenticated access 1`] = ` 10 | <div> 11 | LOGIN 12 | </div> 13 | `; 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/components/__snapshots__/SystemAlerts.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SystemAlerts should render all zones 1`] = ` 4 | .c1 { 5 | position: fixed; 6 | z-index: 1000; 7 | left: 16px; 8 | top: 16px; 9 | width: 26rem; 10 | } 11 | 12 | .c1 > div > * + * { 13 | margin-top: 16px; 14 | } 15 | 16 | .c2 { 17 | position: fixed; 18 | z-index: 1000; 19 | right: 16px; 20 | top: 16px; 21 | width: 26rem; 22 | } 23 | 24 | .c2 > div > * + * { 25 | margin-top: 16px; 26 | } 27 | 28 | .c3 { 29 | position: fixed; 30 | z-index: 1000; 31 | bottom: 16px; 32 | left: 16px; 33 | width: 26rem; 34 | } 35 | 36 | .c3 > div > * + * { 37 | margin-top: 16px; 38 | } 39 | 40 | .c4 { 41 | position: fixed; 42 | z-index: 1000; 43 | bottom: 16px; 44 | right: 16px; 45 | width: 26rem; 46 | } 47 | 48 | .c4 > div > * + * { 49 | margin-top: 16px; 50 | } 51 | 52 | .c0 { 53 | pointer-events: none; 54 | position: fixed; 55 | z-index: 1000; 56 | } 57 | 58 | @media (min-width:768px) { 59 | .c1 { 60 | width: 32rem; 61 | } 62 | } 63 | 64 | @media (min-width:768px) { 65 | .c2 { 66 | width: 32rem; 67 | } 68 | } 69 | 70 | @media (min-width:768px) { 71 | .c3 { 72 | width: 32rem; 73 | } 74 | } 75 | 76 | @media (min-width:768px) { 77 | .c4 { 78 | width: 32rem; 79 | } 80 | } 81 | 82 | <SystemAlertsWrapper 83 | key="SystemAlerts" 84 | > 85 | <div 86 | className="c0" 87 | > 88 | <TopLeft> 89 | <div 90 | className="c1" 91 | > 92 | <div 93 | className="transition" 94 | /> 95 | </div> 96 | </TopLeft> 97 | <TopRight> 98 | <div 99 | className="c2" 100 | > 101 | <div 102 | className="transition" 103 | /> 104 | </div> 105 | </TopRight> 106 | <BottomLeft> 107 | <div 108 | className="c3" 109 | > 110 | <div 111 | className="transition" 112 | /> 113 | </div> 114 | </BottomLeft> 115 | <BottomRight> 116 | <div 117 | className="c4" 118 | > 119 | <div 120 | className="transition" 121 | /> 122 | </div> 123 | </BottomRight> 124 | </div> 125 | </SystemAlertsWrapper> 126 | `; 127 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/constants/__snapshots__/index.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`constants should match the snapshot 1`] = ` 4 | Object { 5 | "EXCEPTION": "EXCEPTION", 6 | "GITHUB_GET_REPOS": "GITHUB_GET_REPOS", 7 | "GITHUB_GET_REPOS_FAILURE": "GITHUB_GET_REPOS_FAILURE", 8 | "GITHUB_GET_REPOS_SUCCESS": "GITHUB_GET_REPOS_SUCCESS", 9 | "HIDE_ALERT": "HIDE_ALERT", 10 | "SHOW_ALERT": "SHOW_ALERT", 11 | "SWITCH_MENU": "SWITCH_MENU", 12 | "USER_LOGIN": "USER_LOGIN", 13 | "USER_LOGIN_FAILURE": "USER_LOGIN_FAILURE", 14 | "USER_LOGIN_SUCCESS": "USER_LOGIN_SUCCESS", 15 | "USER_LOGOUT": "USER_LOGOUT", 16 | "USER_LOGOUT_FAILURE": "USER_LOGOUT_FAILURE", 17 | "USER_LOGOUT_SUCCESS": "USER_LOGOUT_SUCCESS", 18 | } 19 | `; 20 | 21 | exports[`constants should match the snapshot 2`] = ` 22 | Object { 23 | "ERROR": "error", 24 | "IDLE": "idle", 25 | "READY": "ready", 26 | "RUNNING": "running", 27 | "SUCCESS": "success", 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/constants/index.spec.js: -------------------------------------------------------------------------------- 1 | import { ActionTypes, STATUS } from 'constants/index'; 2 | 3 | describe('constants', () => { 4 | it('should match the snapshot', () => { 5 | expect(ActionTypes).toMatchSnapshot(); 6 | expect(STATUS).toMatchSnapshot(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/index.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | jest.mock('redux-persist/lib/integration/react', () => ({ 4 | PersistGate: () => <div id="persist-gate" />, 5 | })); 6 | 7 | describe('index/app', () => { 8 | beforeAll(() => { 9 | process.env.NODE_ENV = 'production'; 10 | }); 11 | 12 | afterAll(() => { 13 | process.env.NODE_ENV = 'test'; 14 | }); 15 | 16 | it('should have mounted the app', () => { 17 | require('index'); 18 | expect(document.getElementById('persist-gate')).not.toBeNull(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/modules/RoutePrivate.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MemoryRouter as Router } from 'react-router-dom'; 3 | import { renderToString } from 'react-dom/server'; 4 | import RoutePrivate from 'components/RoutePrivate'; 5 | 6 | describe('modules/RoutePrivate', () => { 7 | it('should redirect for unauthenticated access', () => { 8 | const render = renderToString( 9 | <Router initialEntries={['/private']}> 10 | <RoutePrivate 11 | exact 12 | path="/private" 13 | component={() => <div>PRIVATE</div>} 14 | isAuthenticated={false} 15 | /> 16 | </Router>, 17 | ); 18 | 19 | expect(render).toMatchSnapshot(); 20 | }); 21 | 22 | it('should allow navigation for authenticated access', () => { 23 | const render = renderToString( 24 | <Router initialEntries={['/private']}> 25 | <RoutePrivate 26 | exact 27 | path="/private" 28 | component={() => <div>PRIVATE</div>} 29 | isAuthenticated={true} 30 | /> 31 | </Router>, 32 | ); 33 | 34 | expect(render).toMatchSnapshot(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/modules/RoutePublic.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MemoryRouter as Router } from 'react-router-dom'; 3 | import { renderToString } from 'react-dom/server'; 4 | import RoutePublic from 'components/RoutePublic'; 5 | 6 | describe('modules/RoutePublic', () => { 7 | it('should render the Login component for unauthenticated access', () => { 8 | const render = renderToString( 9 | <Router initialEntries={['/login']}> 10 | <RoutePublic 11 | exact 12 | path="/login" 13 | component={() => <div>LOGIN</div>} 14 | isAuthenticated={false} 15 | /> 16 | </Router>, 17 | ); 18 | 19 | expect(render).toMatchSnapshot(); 20 | }); 21 | 22 | it('should redirect to /private for authenticated access', () => { 23 | const render = renderToString( 24 | <Router initialEntries={['/login']}> 25 | <RoutePublic 26 | exact 27 | path="/login" 28 | component={() => <div>LOGIN</div>} 29 | isAuthenticated={true} 30 | /> 31 | </Router>, 32 | ); 33 | 34 | expect(render).toMatchSnapshot(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/modules/__snapshots__/RoutePrivate.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`modules/RoutePrivate should allow navigation for authenticated access 1`] = ` 4 | <div> 5 | PRIVATE 6 | </div> 7 | `; 8 | 9 | exports[`modules/RoutePrivate should redirect for unauthenticated access 1`] = ` 10 | <div> 11 | REDIRECT 12 | </div> 13 | `; 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/modules/__snapshots__/RoutePublic.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`modules/RoutePublic should redirect to /private for authenticated access 1`] = ` 4 | <div> 5 | REDIRECT 6 | </div> 7 | `; 8 | 9 | exports[`modules/RoutePublic should render the Login component for unauthenticated access 1`] = ` 10 | <div> 11 | LOGIN 12 | </div> 13 | `; 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/modules/__snapshots__/client.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`client request should execute a GET successfully 1`] = ` 4 | Object { 5 | "hello": "world", 6 | } 7 | `; 8 | 9 | exports[`client request should execute a POST successfully 1`] = `""`; 10 | 11 | exports[`client request should reject for a not found error 1`] = `undefined`; 12 | 13 | exports[`client request should reject for a server error with JSON response 1`] = `undefined`; 14 | 15 | exports[`client request should reject for a server error with no response 1`] = `undefined`; 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/modules/__snapshots__/helpers.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`helpers datasetToObject should convert DOMElement data to object 1`] = ` 4 | Object { 5 | "minuteMaid": "no", 6 | "rule": "yes", 7 | } 8 | `; 9 | 10 | exports[`helpers keyMirror should return an object with mirrored keys 1`] = ` 11 | Object { 12 | "EXCEPTION": "EXCEPTION", 13 | "SWITCH_MENU": "SWITCH_MENU", 14 | "USER_LOGIN": "USER_LOGIN", 15 | "USER_LOGIN_FAILURE": "USER_LOGIN_FAILURE", 16 | "USER_LOGIN_SUCCESS": "USER_LOGIN_SUCCESS", 17 | "USER_LOGOUT": "USER_LOGOUT", 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/reducers/__snapshots__/app.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App should handle HIDE_ALERT 1`] = ` 4 | Object { 5 | "alerts": Array [], 6 | } 7 | `; 8 | 9 | exports[`App should handle SHOW_ALERT 1`] = ` 10 | Object { 11 | "alerts": Array [ 12 | Object { 13 | "icon": undefined, 14 | "id": "test", 15 | "message": "HELLO", 16 | "position": "bottom-right", 17 | "timeout": 5, 18 | "variant": "dark", 19 | }, 20 | ], 21 | } 22 | `; 23 | 24 | exports[`App should return the initial state 1`] = ` 25 | Object { 26 | "alerts": Array [], 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/reducers/__snapshots__/github.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Github should handle GITHUB_GET_REPOS 1`] = ` 4 | Object { 5 | "repos": Object { 6 | "data": Object { 7 | "undefined": Array [], 8 | }, 9 | "message": "", 10 | "query": undefined, 11 | "status": "running", 12 | }, 13 | } 14 | `; 15 | 16 | exports[`Github should handle GITHUB_GET_REPOS_FAILURE 1`] = ` 17 | Object { 18 | "repos": Object { 19 | "data": Object {}, 20 | "message": "Something went wrong", 21 | "query": "", 22 | "status": "error", 23 | }, 24 | } 25 | `; 26 | 27 | exports[`Github should handle GITHUB_GET_REPOS_SUCCESS 1`] = ` 28 | Object { 29 | "repos": Object { 30 | "data": Object { 31 | "": Array [], 32 | }, 33 | "message": "", 34 | "query": "", 35 | "status": "success", 36 | }, 37 | } 38 | `; 39 | 40 | exports[`Github should return the initial state 1`] = ` 41 | Object { 42 | "repos": Object { 43 | "data": Object {}, 44 | "message": "", 45 | "query": "", 46 | "status": "idle", 47 | }, 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/reducers/__snapshots__/user.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`User should handle USER_LOGIN 1`] = ` 4 | Object { 5 | "isAuthenticated": false, 6 | "status": "running", 7 | } 8 | `; 9 | 10 | exports[`User should handle USER_LOGIN_SUCCESS 1`] = ` 11 | Object { 12 | "isAuthenticated": true, 13 | "status": "ready", 14 | } 15 | `; 16 | 17 | exports[`User should handle USER_LOGOUT 1`] = ` 18 | Object { 19 | "isAuthenticated": false, 20 | "status": "running", 21 | } 22 | `; 23 | 24 | exports[`User should handle USER_LOGOUT_SUCCESS 1`] = ` 25 | Object { 26 | "isAuthenticated": false, 27 | "status": "idle", 28 | } 29 | `; 30 | 31 | exports[`User should return the initial state 1`] = ` 32 | Object { 33 | "isAuthenticated": false, 34 | "status": "idle", 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/reducers/app.spec.js: -------------------------------------------------------------------------------- 1 | import reducer from 'reducers/app'; 2 | import { hideAlert, showAlert } from 'actions/app'; 3 | import { ActionTypes } from 'constants/index'; 4 | 5 | describe('App', () => { 6 | let app = reducer.app(undefined, {}); 7 | 8 | it('should return the initial state', () => { 9 | expect(reducer.app(app, {})).toMatchSnapshot(); 10 | }); 11 | 12 | it(`should handle ${ActionTypes.SHOW_ALERT}`, () => { 13 | app = reducer.app(app, showAlert('HELLO', { id: 'test', type: 'success' })); 14 | expect(app).toMatchSnapshot(); 15 | }); 16 | 17 | it(`should handle ${ActionTypes.HIDE_ALERT}`, () => { 18 | app = reducer.app(app, hideAlert('test')); 19 | expect(app).toMatchSnapshot(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/reducers/github.spec.js: -------------------------------------------------------------------------------- 1 | import reducer from 'reducers/github'; 2 | import { ActionTypes } from 'constants/index'; 3 | 4 | describe('Github', () => { 5 | it('should return the initial state', () => { 6 | expect(reducer.github(undefined, {})).toMatchSnapshot(); 7 | }); 8 | 9 | it(`should handle ${ActionTypes.GITHUB_GET_REPOS}`, () => { 10 | expect( 11 | reducer.github(undefined, { 12 | type: ActionTypes.GITHUB_GET_REPOS, 13 | payload: { q: 'react' }, 14 | }), 15 | ).toMatchSnapshot(); 16 | }); 17 | 18 | it(`should handle ${ActionTypes.GITHUB_GET_REPOS_SUCCESS}`, () => { 19 | expect( 20 | reducer.github(undefined, { 21 | type: ActionTypes.GITHUB_GET_REPOS_SUCCESS, 22 | payload: {}, 23 | }), 24 | ).toMatchSnapshot(); 25 | }); 26 | 27 | it(`should handle ${ActionTypes.GITHUB_GET_REPOS_FAILURE}`, () => { 28 | expect( 29 | reducer.github(undefined, { 30 | type: ActionTypes.GITHUB_GET_REPOS_FAILURE, 31 | payload: {}, 32 | }), 33 | ).toMatchSnapshot(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/reducers/user.spec.js: -------------------------------------------------------------------------------- 1 | import reducer from 'reducers/user'; 2 | import { ActionTypes } from 'constants/index'; 3 | 4 | describe('User', () => { 5 | it('should return the initial state', () => { 6 | expect(reducer.user(undefined, {})).toMatchSnapshot(); 7 | }); 8 | 9 | it(`should handle ${ActionTypes.USER_LOGIN}`, () => { 10 | expect(reducer.user(undefined, { type: ActionTypes.USER_LOGIN })).toMatchSnapshot(); 11 | }); 12 | 13 | it(`should handle ${ActionTypes.USER_LOGIN_SUCCESS}`, () => { 14 | expect(reducer.user(undefined, { type: ActionTypes.USER_LOGIN_SUCCESS })).toMatchSnapshot(); 15 | }); 16 | 17 | it(`should handle ${ActionTypes.USER_LOGOUT}`, () => { 18 | expect(reducer.user(undefined, { type: ActionTypes.USER_LOGOUT })).toMatchSnapshot(); 19 | }); 20 | 21 | it(`should handle ${ActionTypes.USER_LOGOUT_SUCCESS}`, () => { 22 | expect(reducer.user(undefined, { type: ActionTypes.USER_LOGOUT_SUCCESS })).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/routes/Home.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Home } from 'routes/Home'; 4 | 5 | const mockDispatch = jest.fn(); 6 | const props = { 7 | dispatch: mockDispatch, 8 | location: {}, 9 | user: {}, 10 | }; 11 | 12 | function setup(ownProps = props) { 13 | return mount(<Home {...ownProps} />); 14 | } 15 | 16 | describe('Home', () => { 17 | const wrapper = setup(); 18 | 19 | it('should render properly', () => { 20 | expect(wrapper).toMatchSnapshot(); 21 | }); 22 | 23 | it('should handle clicks', () => { 24 | wrapper.find('Button').simulate('click'); 25 | 26 | expect(mockDispatch).toHaveBeenCalledWith({ 27 | type: 'USER_LOGIN', 28 | payload: {}, 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/routes/NotFound.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import NotFound from 'routes/NotFound'; 4 | 5 | function setup() { 6 | return mount(<NotFound />); 7 | } 8 | 9 | describe('NotFound', () => { 10 | const wrapper = setup(); 11 | 12 | it('should render properly', () => { 13 | expect(wrapper).toMatchSnapshot(); 14 | }); 15 | 16 | it('should redirect to home', () => { 17 | navigate({ pathname: '/some-page' }); 18 | 19 | wrapper.find('Link').simulate('click'); 20 | expect(location.pathname).toBe('/'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/routes/Private.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Private from 'routes/Private'; 4 | 5 | function setup() { 6 | const props = { 7 | dispatch: () => {}, 8 | location: {}, 9 | }; 10 | 11 | return shallow(<Private {...props} />); 12 | } 13 | 14 | describe('Private', () => { 15 | const wrapper = setup(); 16 | 17 | it('should render properly', () => { 18 | expect(wrapper).toMatchSnapshot(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/routes/__snapshots__/Private.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Private should render properly 1`] = ` 4 | <Screen 5 | data-testid="PrivateWrapper" 6 | key="Private" 7 | minHeight="100vh" 8 | > 9 | <Container 10 | verticalPadding={true} 11 | > 12 | <Header> 13 | <Heading 14 | as="h1" 15 | > 16 | Oh hai! 17 | </Heading> 18 | <Paragraph 19 | as="p" 20 | > 21 | You can get this boilerplate 22 | 23 | <Link 24 | as="a" 25 | href="https://github.com/gilbarbara/react-redux-saga-boilerplate/" 26 | target="_blank" 27 | > 28 | here 29 | </Link> 30 | </Paragraph> 31 | </Header> 32 | <Box 33 | mb={4} 34 | textAlign="center" 35 | > 36 | <Heading 37 | as="h5" 38 | > 39 | Here's some GitHub data 40 | </Heading> 41 | <Text 42 | as="span" 43 | fontSize={1} 44 | > 45 | <i> 46 | *Just to have some requests in the sagas... 47 | </i> 48 | </Text> 49 | </Box> 50 | <Connect(GitHub) /> 51 | </Container> 52 | </Screen> 53 | `; 54 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/sagas/__snapshots__/app.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`app should have the expected watchers 1`] = ` 4 | Object { 5 | "fork": Array [ 6 | Object { 7 | "@@redux-saga/IO": true, 8 | "combinator": false, 9 | "payload": Object { 10 | "args": Array [ 11 | "SWITCH_MENU", 12 | "@@redux-saga-test-plan/json/function/switchMenu", 13 | ], 14 | "context": null, 15 | "fn": "@@redux-saga-test-plan/json/function/takeLatest", 16 | }, 17 | "type": "FORK", 18 | }, 19 | ], 20 | "take": Array [ 21 | Object { 22 | "@@redux-saga/IO": true, 23 | "combinator": false, 24 | "payload": Object { 25 | "pattern": "SWITCH_MENU", 26 | }, 27 | "type": "TAKE", 28 | }, 29 | ], 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/sagas/__snapshots__/github.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`github should have the expected watchers 1`] = ` 4 | Object { 5 | "fork": Array [ 6 | Object { 7 | "@@redux-saga/IO": true, 8 | "combinator": false, 9 | "payload": Object { 10 | "args": Array [ 11 | "GITHUB_GET_REPOS", 12 | "@@redux-saga-test-plan/json/function/getRepos", 13 | ], 14 | "context": null, 15 | "fn": "@@redux-saga-test-plan/json/function/takeLatest", 16 | }, 17 | "type": "FORK", 18 | }, 19 | ], 20 | "take": Array [ 21 | Object { 22 | "@@redux-saga/IO": true, 23 | "combinator": false, 24 | "payload": Object { 25 | "pattern": "GITHUB_GET_REPOS", 26 | }, 27 | "type": "TAKE", 28 | }, 29 | ], 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/sagas/__snapshots__/user.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`user should have the expected watchers 1`] = ` 4 | Object { 5 | "fork": Array [ 6 | Object { 7 | "@@redux-saga/IO": true, 8 | "combinator": false, 9 | "payload": Object { 10 | "args": Array [ 11 | "USER_LOGIN", 12 | "@@redux-saga-test-plan/json/function/login", 13 | ], 14 | "context": null, 15 | "fn": "@@redux-saga-test-plan/json/function/takeLatest", 16 | }, 17 | "type": "FORK", 18 | }, 19 | Object { 20 | "@@redux-saga/IO": true, 21 | "combinator": false, 22 | "payload": Object { 23 | "args": Array [ 24 | "USER_LOGOUT", 25 | "@@redux-saga-test-plan/json/function/logout", 26 | ], 27 | "context": null, 28 | "fn": "@@redux-saga-test-plan/json/function/takeLatest", 29 | }, 30 | "type": "FORK", 31 | }, 32 | ], 33 | "take": Array [ 34 | Object { 35 | "@@redux-saga/IO": true, 36 | "combinator": false, 37 | "payload": Object { 38 | "pattern": "USER_LOGIN", 39 | }, 40 | "type": "TAKE", 41 | }, 42 | Object { 43 | "@@redux-saga/IO": true, 44 | "combinator": false, 45 | "payload": Object { 46 | "pattern": "USER_LOGOUT", 47 | }, 48 | "type": "TAKE", 49 | }, 50 | ], 51 | } 52 | `; 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/sagas/app.spec.js: -------------------------------------------------------------------------------- 1 | import { expectSaga } from 'redux-saga-test-plan'; 2 | import { combineReducers } from 'redux'; 3 | import rootReducer from 'reducers'; 4 | 5 | import app, { switchMenu } from 'sagas/app'; 6 | import { ActionTypes } from 'constants/index'; 7 | 8 | describe('app', () => { 9 | it('should have the expected watchers', done => 10 | expectSaga(app) 11 | .run({ silenceTimeout: true }) 12 | .then(saga => { 13 | expect(saga).toMatchSnapshot(); 14 | done(); 15 | })); 16 | 17 | it('should have the switch menu saga', () => 18 | expectSaga(switchMenu, { payload: { query: 'react' } }) 19 | .withReducer(combineReducers({ ...rootReducer })) 20 | .put({ 21 | type: ActionTypes.GITHUB_GET_REPOS, 22 | payload: { query: 'react' }, 23 | }) 24 | .run()); 25 | }); 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/sagas/github.spec.js: -------------------------------------------------------------------------------- 1 | import { expectSaga } from 'redux-saga-test-plan'; 2 | 3 | import github, { getRepos } from 'sagas/github'; 4 | import { ActionTypes } from 'constants/index'; 5 | 6 | jest.mock('modules/client', () => ({ 7 | request: () => ({ items: [] }), 8 | })); 9 | 10 | describe('github', () => { 11 | it('should have the expected watchers', done => 12 | expectSaga(github) 13 | .run({ silenceTimeout: true }) 14 | .then(saga => { 15 | expect(saga).toMatchSnapshot(); 16 | done(); 17 | })); 18 | 19 | it('should have the repos saga', () => 20 | expectSaga(getRepos, { payload: { query: 'react' } }) 21 | .put({ 22 | type: ActionTypes.GITHUB_GET_REPOS_SUCCESS, 23 | payload: { 24 | data: [], 25 | }, 26 | }) 27 | .run()); 28 | }); 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/sagas/user.spec.js: -------------------------------------------------------------------------------- 1 | import { expectSaga } from 'redux-saga-test-plan'; 2 | 3 | import user, { login, logout } from 'sagas/user'; 4 | import { ActionTypes } from 'constants/index'; 5 | 6 | describe('user', () => { 7 | it('should have the expected watchers', done => { 8 | expectSaga(user) 9 | .run({ silenceTimeout: true }) 10 | .then(saga => { 11 | expect(saga).toMatchSnapshot(); 12 | done(); 13 | }); 14 | }); 15 | 16 | it('should match the login saga', () => 17 | expectSaga(login) 18 | .delay(400) 19 | .put({ 20 | type: ActionTypes.USER_LOGIN_SUCCESS, 21 | }) 22 | .run(500)); 23 | 24 | it('should match the logout saga', () => 25 | expectSaga(logout) 26 | .delay(200) 27 | .put({ 28 | type: ActionTypes.USER_LOGOUT_SUCCESS, 29 | }) 30 | .run()); 31 | }); 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/store/__snapshots__/index.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`store should have a persistor 1`] = ` 4 | Object { 5 | "bootstrapped": true, 6 | "registry": Array [], 7 | } 8 | `; 9 | 10 | exports[`store should have a store 1`] = ` 11 | Object { 12 | "_persist": Object { 13 | "rehydrated": true, 14 | "version": -1, 15 | }, 16 | "app": Object { 17 | "alerts": Array [], 18 | }, 19 | "github": Object { 20 | "repos": Object { 21 | "data": Object {}, 22 | "message": "", 23 | "query": "", 24 | "status": "idle", 25 | }, 26 | }, 27 | "user": Object { 28 | "isAuthenticated": false, 29 | "status": "idle", 30 | }, 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/test/store/index.spec.js: -------------------------------------------------------------------------------- 1 | import { store, persistor } from 'store'; 2 | 3 | describe('store', () => { 4 | it('should have a store', () => { 5 | expect(store.getState()).toMatchSnapshot(); 6 | }); 7 | 8 | it('should have a persistor', () => { 9 | expect(persistor.getState()).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/tools/deploy.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable no-console */ 2 | const { exec } = require('child_process'); 3 | const chalk = require('chalk'); 4 | const publish = require('./publish'); 5 | 6 | function deploy() { 7 | const start = Date.now(); 8 | console.log(chalk.blue('Bundling...')); 9 | 10 | return exec('npm run build', errBuild => { 11 | if (errBuild) { 12 | console.log(chalk.red(errBuild)); 13 | process.exit(1); 14 | } 15 | 16 | console.log(`Bundled in ${(Date.now() - start) / 1000} s`); 17 | 18 | publish(); 19 | }); 20 | } 21 | 22 | module.exports = deploy; 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/tools/publish.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable no-console */ 2 | const chalk = require('chalk'); 3 | const Rsync = require('rsync'); 4 | 5 | const paths = require('../config/paths'); 6 | 7 | function publish() { 8 | console.log(chalk.blue('Publishing...')); 9 | const rsync = new Rsync() 10 | .shell('ssh') 11 | .exclude('.DS_Store') 12 | .flags('az') 13 | .source(`${paths.appBuild}/`) 14 | .destination( 15 | 'reactboilerplate@react-boilerplate.com:/home/reactboilerplate/public_html/redux-saga', 16 | ); 17 | 18 | rsync.execute((error, code, cmd) => { 19 | if (error) { 20 | console.log(chalk.red('Something went wrong...', error, code, cmd)); 21 | process.exit(1); 22 | } 23 | 24 | console.log(chalk.green('Published')); 25 | }); 26 | } 27 | 28 | module.exports = publish; 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/build-push.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG?Variable not set} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | sh ./scripts/build.sh 9 | 10 | docker-compose -f docker-compose.yml push 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG?Variable not set} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | docker-compose \ 9 | -f docker-compose.yml \ 10 | build 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | DOMAIN=${DOMAIN?Variable not set} \ 7 | TRAEFIK_TAG=${TRAEFIK_TAG?Variable not set} \ 8 | STACK_NAME=${STACK_NAME?Variable not set} \ 9 | TAG=${TAG?Variable not set} \ 10 | docker-compose \ 11 | -f docker-compose.yml \ 12 | config > docker-stack.yml 13 | 14 | docker-auto-labels docker-stack.yml 15 | 16 | docker stack deploy -c docker-stack.yml --with-registry-auth "${STACK_NAME?Variable not set}" 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/test-local.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | docker-compose down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 7 | 8 | if [ $(uname -s) = "Linux" ]; then 9 | echo "Remove __pycache__ files" 10 | sudo find . -type d -name __pycache__ -exec rm -r {} \+ 11 | fi 12 | 13 | docker-compose build 14 | docker-compose up -d 15 | docker-compose exec -T backend bash /app/tests-start.sh "$@" 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | DOMAIN=backend \ 7 | SMTP_HOST="" \ 8 | TRAEFIK_PUBLIC_NETWORK_IS_EXTERNAL=false \ 9 | INSTALL_DEV=true \ 10 | docker-compose \ 11 | -f docker-compose.yml \ 12 | config > docker-stack.yml 13 | 14 | docker-compose -f docker-stack.yml build 15 | docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 16 | docker-compose -f docker-stack.yml up -d 17 | docker-compose -f docker-stack.yml exec -T backend bash /app/tests-start.sh "$@" 18 | docker-compose -f docker-stack.yml down -v --remove-orphans 19 | --------------------------------------------------------------------------------