diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml deleted file mode 100644 index a0d6d87cf..000000000 --- a/.github/workflows/cypress.yml +++ /dev/null @@ -1,161 +0,0 @@ -name: Cypress Tests -on: [deployment_status] - -jobs: - Setup: - if: github.event.deployment_status.state == 'success' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: pipx install pipenv - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - # Can't use cache because of https://github.com/actions/cache/issues/319 - # cache: 'pipenv' - - name: Install bootstrapper dependencies - run: pipenv install --dev --deploy - - run: | - config_file=$(./scripts/vue_or_react.sh) - pipenv run cookiecutter . --config-file $config_file --no-input -f - cat $config_file - - uses: actions/upload-artifact@v4 - with: - name: my_project - path: my_project/ - retention-days: 1 - Chrome: - needs: Setup - runs-on: ubuntu-latest - container: - image: cypress/browsers:node16.14.2-slim-chrome103-ff102 # https://github.com/cypress-io/cypress-docker-images/tree/master/browsers - options: --user 1001 - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: my_project - path: my_project/ - - uses: actions/setup-node@v4 - with: - node-version: 16 - - name: Install frontend dependencies - env: - NPM_CONFIG_PRODUCTION: false - working-directory: ./my_project/client - run: npm install - - name: Run against ${{ github.event.deployment_status.environment_url }} - uses: cypress-io/github-action@v6 - with: - working-directory: my_project/client - browser: chrome - env: - NPM_CONFIG_PRODUCTION: false - CYPRESS_TEST_USER_EMAIL: "cypress@example.com" - CYPRESS_TEST_USER_PASS: ${{ secrets.CYPRESS_TEST_USER_PASS }} - CYPRESS_baseUrl: ${{ github.event.deployment_status.environment_url }} - Firefox: - needs: Setup - runs-on: ubuntu-latest - container: - image: cypress/browsers:node16.14.2-slim-chrome103-ff102 # https://github.com/cypress-io/cypress-docker-images/tree/master/browsers - options: --user 1001 - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: my_project - path: my_project/ - - uses: actions/setup-node@v4 - with: - node-version: 16 - - name: Install frontend dependencies - env: - NPM_CONFIG_PRODUCTION: false - working-directory: ./my_project/client - run: npm install - - name: Run against ${{ github.event.deployment_status.environment_url }} - uses: cypress-io/github-action@v6 - with: - working-directory: my_project/client - browser: firefox - env: - NPM_CONFIG_PRODUCTION: false - CYPRESS_TEST_USER_EMAIL: "cypress@example.com" - CYPRESS_TEST_USER_PASS: ${{ secrets.CYPRESS_TEST_USER_PASS }} - CYPRESS_baseUrl: ${{ github.event.deployment_status.environment_url }} - - configuremobile: - needs: Setup - runs-on: ubuntu-latest - outputs: - BUILD_MOBILE_APP: ${{ steps.checkdiff.outputs.BUILD_MOBILE_APP }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 5 - name: check diff - - id: checkdiff - run: | - git fetch origin main - echo $(git diff --name-only origin/main -- | grep "/clients/mobile/react-native/" | wc -l) - echo "BUILD_MOBILE_APP=$(git diff --name-only origin/main -- | grep "/clients/mobile/react-native/" | wc -l)" >> $GITHUB_OUTPUT - - getprnumber: - runs-on: ubuntu-latest - needs: configuremobile - if: needs.configuremobile.outputs.BUILD_MOBILE_APP != 0 - steps: - - uses: jwalton/gh-find-current-pr@master - id: findPr - - name: Set name for PR - if: success() && steps.findPr.outputs.number - run: echo "PR=pr-${PR}" >> $GITHUB_ENV - env: - PR: ${{ steps.findPr.outputs.pr }} - outputs: - PR: ${{ env.PR }} - - publish: - runs-on: ubuntu-latest - needs: getprnumber - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: my_project - path: my_project/ - - uses: actions/cache@v4 - with: - path: "**/node_modules" - key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} - - name: 🏗 Setup Node - uses: actions/setup-node@v4 - with: - node-version: 18.x - - - name: 🏗 Setup Expo and EAS - uses: expo/expo-github-action@v7 - with: - expo-version: latest - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - - - name: 📦 Install dependencies - working-directory: ./my_project/mobile - run: yarn install - - - name: Copy Config files - run: | - ls - cp resources/app-bs.config.js my_project/mobile/app.config.js - cp resources/eas-bs.json my_project/mobile/eas.json - - - name: 🚀 Publish preview - working-directory: ./my_project/mobile - run: | - eas update --branch="${{ needs.getprnumber.outputs.PR }}" --non-interactive --auto - echo "${{ env.BACKEND_SERVER_URL }} and ${{ needs.getprnumber.outputs.PR }}" - env: - EXPO_PUBLIC_BACKEND_SERVER_URL: "${{ github.event.deployment_status.environment_url }}" - EXPO_PUBLIC_ROLLBAR_ACCESS_TOKEN: "1a19e5da05b2435b802d5a81aba2bbd7" \ No newline at end of file diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..e786db5bc --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,59 @@ +name: E2E Tests +on: [deployment_status] + +jobs: + Setup: + if: github.event.deployment_status.state == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pipx install pipenv + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # Can't use cache because of https://github.com/actions/cache/issues/319 + # cache: 'pipenv' + - name: Install bootstrapper dependencies + run: pipenv install --dev --deploy + - run: | + config_file=$(./scripts/vue_or_react.sh) + pipenv run cookiecutter . --config-file $config_file --no-input -f + cat $config_file + - uses: actions/upload-artifact@v4 + with: + name: my_project + path: my_project/ + retention-days: 1 + Playwright: + needs: Setup + timeout-minutes: 60 + runs-on: ubuntu-latest + if: github.event.deployment_status.state == 'success' + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: my_project + path: my_project/ + - uses: actions/setup-node@v4 + with: + node-version: 18 + - name: 📦 Install frontend dependencies + working-directory: ./my_project/client + run: npm install + - name: 🎭 Install Playwright + working-directory: ./my_project/client + run: npx playwright install --with-deps + - name: Run Playwright tests against ${{ github.event.deployment_status.environment_url }} + working-directory: ./my_project/client + run: npx playwright test --reporter=html + env: + NPM_CONFIG_PRODUCTION: false + PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.environment_url }} + PLAYWRIGHT_TEST_USER_PASS: ${{ secrets.PLAYWRIGHT_TEST_USER_PASS }} + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: my_project/client/playwright-report/ + retention-days: 30 diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index a8540e91e..e6f747610 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -9,7 +9,7 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" cache: "pipenv" - run: pipenv install --dev --deploy - name: Run Black @@ -21,7 +21,7 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" cache: "pipenv" - run: pipenv install --dev --deploy - name: Run flake8 @@ -36,7 +36,7 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" cache: "pipenv" - name: 🐍 Install python dependencies run: pipenv install --dev --deploy @@ -58,7 +58,7 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" cache: "pipenv" - name: 🐍 Install python dependencies run: pipenv install --dev --deploy @@ -80,7 +80,7 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" cache: "pipenv" - name: 🐍 Install python dependencies run: pipenv install --dev --deploy @@ -110,7 +110,7 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" cache: "pipenv" - name: 🐍 Install python dependencies run: pipenv install --dev --deploy diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 000000000..9413736a6 --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,96 @@ +name: Mobile Deployment +on: [deployment_status, workflow_dispatch] + +jobs: + Setup: + if: github.event.deployment_status.state == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pipx install pipenv + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # Can't use cache because of https://github.com/actions/cache/issues/319 + # cache: 'pipenv' + - name: Install bootstrapper dependencies + run: pipenv install --dev --deploy + - run: | + config_file=$(./scripts/vue_or_react.sh) + pipenv run cookiecutter . --config-file $config_file --no-input -f + cat $config_file + - uses: actions/upload-artifact@v4 + with: + name: my_project + path: my_project/ + retention-days: 1 + configuremobile: + needs: Setup + runs-on: ubuntu-latest + outputs: + BUILD_MOBILE_APP: ${{ steps.checkdiff.outputs.BUILD_MOBILE_APP }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 5 + name: check diff + - id: checkdiff + run: | + git fetch origin main + if [ {{ "${{ github.event_name }}" }} == "workflow_dispatch" ]; then + echo "This workflow was triggered by a workflow_dispatch event." + echo "BUILD_MOBILE_APP=1" >> $GITHUB_OUTPUT + else + echo $(git diff --name-only origin/main -- | grep "/clients/mobile/react-native/" | wc -l) + echo "BUILD_MOBILE_APP=$(git diff --name-only origin/main -- | grep "/clients/mobile/react-native/" | wc -l)" >> $GITHUB_OUTPUT + fi + + publish: + if: needs.configuremobile.outputs.BUILD_MOBILE_APP != 0 + needs: configuremobile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: my_project + path: my_project/ + - name: Create config files + run: | + cp resources/app.config.vars.txt my_project/resources/app.config.vars.txt + cp resources/eas.vars.txt my_project/resources/eas.vars.txt + cd my_project + current_dir=$(pwd) + . scripts/setup_mobile_config.sh "$current_dir/mobile/app.config.js" "$current_dir/resources/app.config.vars.txt" + . scripts/setup_mobile_config.sh "$current_dir/mobile/eas.json" "$current_dir/resources/eas.vars.txt" + + # - uses: actions/cache@v4 + # with: + # path: "**/node_modules" + # key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} + - name: 🏗 Setup Node + uses: actions/setup-node@v4 + with: + node-version: 18.x + # cache: npm + # cache-dependency-path: ./mobile/package-lock.json + + - name: 🏗 Setup Expo and EAS + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: 📦 Install dependencies + working-directory: ./my_project/mobile + run: npm install + + - name: 🚀 Publish preview + working-directory: ./my_project/mobile + run: | + eas update --branch="${{ github.event.deployment_status.environment_url }}" --non-interactive --auto + echo "${{ env.BACKEND_SERVER_URL }} and ${{ github.event.deployment_status.environment_url }}" + env: + EXPO_PUBLIC_BACKEND_SERVER_URL: "${{ github.event.deployment_status.environment_url }}" + EXPO_PUBLIC_ROLLBAR_ACCESS_TOKEN: "1a19e5da05b2435b802d5a81aba2bbd7" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a6b5bcfaf..1e4e3ebeb 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -9,8 +9,8 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: '3.10' - cache: 'pipenv' + python-version: "3.12" + cache: "pipenv" - run: pipenv install --dev --deploy - name: Test with pytest run: pipenv run pytest @@ -35,8 +35,8 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: '3.10' - cache: 'pipenv' + python-version: "3.12" + cache: "pipenv" - name: Install bootstrapper dependencies run: pipenv install --dev --deploy - run: | @@ -49,4 +49,4 @@ jobs: mkdir -p client/dist/static pipenv run python server/manage.py collectstatic pipenv run pytest --mccabe --cov=my_project -vv server/my_project - pipenv run coverage report --fail-under=20 + pipenv run coverage report --fail-under=60 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..b93312356 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "zshdb", + "request": "launch", + "name": "Zsh-Debug (select script from list of sh files)", + "cwd": "${workspaceFolder}", + "program": "${command:SelectScriptName}", + "args": ["eas.json",".eas-json-values"] + }, + + ] +} \ No newline at end of file diff --git a/Pipfile b/Pipfile index bacfbd13b..550df74ff 100644 --- a/Pipfile +++ b/Pipfile @@ -8,8 +8,8 @@ cookiecutter = "==2.1.1" binaryornot = "==0.4.4" black = "==22.3.0" isort = "==5.9.3" -flake8 = "==3.9.2" -flake8-isort = "==4.0.0" +flake8 = "==7.1.1" +flake8-isort = "==6.1.1" pre-commit = "==2.13.0" tox = "==3.24.0" pytest = "==6.2.4" @@ -18,9 +18,8 @@ pytest-instafail = "==0.4.2" PyYAML = "==6.0.1" PyGithub = "==1.55" Jinja2 = "==3.0.1" -jinja2-time = "*" [dev-packages] [requires] -python_version = "3.10" +python_version = "3.12" diff --git a/Pipfile.lock b/Pipfile.lock index c863cf9ba..261cff9e8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "7edf08a5a1f3deb5b32e4ef8e2be3f940bab4365bb0760123c19e3447b7df41e" + "sha256": "3c3679d20045fdb32902167a0a0877c4def95b32d37dfa26921a25032d4ec81f" }, "pipfile-spec": 6, "requires": { - "python_version": "3.10" + "python_version": "3.12" }, "sources": [ { @@ -26,11 +26,11 @@ }, "attrs": { "hashes": [ - "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", - "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", + "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" ], "markers": "python_version >= '3.7'", - "version": "==23.2.0" + "version": "==24.2.0" }, "binaryornot": { "hashes": [ @@ -72,69 +72,84 @@ }, "certifi": { "hashes": [ - "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516", - "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56" + "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" ], "markers": "python_version >= '3.6'", - "version": "==2024.6.2" + "version": "==2024.8.30" }, "cffi": { "hashes": [ - "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", - "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", - "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", - "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", - "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", - "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", - "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", - "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", - "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", - "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", - "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", - "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", - "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", - "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", - "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", - "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", - "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", - "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", - "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", - "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", - "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", - "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", - "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", - "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", - "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", - "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", - "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", - "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", - "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", - "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", - "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", - "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", - "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", - "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", - "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", - "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", - "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", - "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", - "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", - "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", - "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", - "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", - "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", - "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", - "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", - "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", - "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", - "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", - "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", - "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", - "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", - "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", + "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", + "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", + "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", + "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", + "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", + "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", + "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", + "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", + "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", + "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", + "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", + "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", + "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", + "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", + "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", + "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", + "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", + "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", + "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", + "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", + "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", + "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", + "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", + "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", + "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", + "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", + "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", + "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", + "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", + "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", + "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", + "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", + "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", + "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", + "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", + "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", + "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", + "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", + "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", + "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", + "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", + "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", + "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", + "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", + "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", + "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", + "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", + "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", + "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", + "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", + "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", + "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", + "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", + "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", + "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", + "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", + "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", + "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", + "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", + "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", + "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", + "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", + "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", + "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", + "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", + "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" ], "markers": "python_version >= '3.8'", - "version": "==1.16.0" + "version": "==1.17.1" }, "cfgv": { "hashes": [ @@ -154,99 +169,114 @@ }, "charset-normalizer": { "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", + "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", + "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", + "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", + "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", + "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", + "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", + "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", + "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", + "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", + "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", + "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", + "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", + "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", + "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", + "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", + "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", + "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", + "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", + "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", + "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", + "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", + "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", + "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", + "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", + "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", + "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", + "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", + "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", + "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", + "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", + "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", + "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", + "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", + "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", + "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", + "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", + "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", + "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", + "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", + "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", + "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", + "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", + "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", + "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", + "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", + "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", + "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", + "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", + "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", + "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", + "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", + "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", + "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", + "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", + "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", + "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", + "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", + "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", + "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", + "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", + "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", + "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", + "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", + "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", + "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", + "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", + "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", + "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", + "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", + "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", + "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", + "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", + "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", + "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", + "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", + "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", + "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", + "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", + "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", + "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", + "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", + "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", + "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", + "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", + "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", + "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", + "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", + "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", + "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", + "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", + "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", + "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", + "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", + "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", + "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", + "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", + "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", + "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", + "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", + "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", + "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", + "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", + "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", + "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" + "version": "==3.4.0" }, "click": { "hashes": [ @@ -275,51 +305,52 @@ }, "distlib": { "hashes": [ - "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", - "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64" + "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", + "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403" ], - "version": "==0.3.8" + "version": "==0.3.9" }, "filelock": { "hashes": [ - "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f", - "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a" + "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", + "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435" ], "markers": "python_version >= '3.8'", - "version": "==3.14.0" + "version": "==3.16.1" }, "flake8": { "hashes": [ - "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b", - "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907" + "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", + "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213" ], "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==3.9.2" + "markers": "python_full_version >= '3.8.1'", + "version": "==7.1.1" }, "flake8-isort": { "hashes": [ - "sha256:2b91300f4f1926b396c2c90185844eb1a3d5ec39ea6138832d119da0a208f4d9", - "sha256:729cd6ef9ba3659512dee337687c05d79c78e1215fdf921ed67e5fe46cce2f3c" + "sha256:0fec4dc3a15aefbdbe4012e51d5531a2eb5fa8b981cdfbc882296a59b54ede12", + "sha256:c1f82f3cf06a80c13e1d09bfae460e9666255d5c780b859f19f8318d420370b3" ], "index": "pypi", - "version": "==4.0.0" + "markers": "python_version >= '3.8'", + "version": "==6.1.1" }, "identify": { "hashes": [ - "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa", - "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d" + "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", + "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98" ], "markers": "python_version >= '3.8'", - "version": "==2.5.36" + "version": "==2.6.1" }, "idna": { "hashes": [ - "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", - "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" ], - "markers": "python_version >= '3.5'", - "version": "==3.7" + "markers": "python_version >= '3.6'", + "version": "==3.10" }, "iniconfig": { "hashes": [ @@ -352,81 +383,82 @@ "sha256:d14eaa4d315e7688daa4969f616f226614350c48730bfa1692d2caebd8c90d40", "sha256:d3eab6605e3ec8b7a0863df09cc1d23714908fa61aa6986a845c20ba488b4efa" ], - "index": "pypi", "version": "==0.2.0" }, "markupsafe": { "hashes": [ - "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", - "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", - "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", - "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", - "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", - "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", - "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", - "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", - "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", - "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", - "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", - "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", - "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", - "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", - "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", - "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", - "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", - "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", - "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", - "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", - "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", - "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", - "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", - "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", - "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", - "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", - "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", - "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", - "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", - "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", - "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", - "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", - "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", - "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", - "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", - "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", - "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", - "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", - "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", - "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", - "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", - "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", - "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", - "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", - "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", - "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", - "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", - "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", - "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", - "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", - "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", - "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", - "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", - "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", - "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", - "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", - "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", - "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", - "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", - "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" - ], - "markers": "python_version >= '3.7'", - "version": "==2.1.5" + "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", + "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", + "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", + "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", + "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", + "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", + "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", + "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", + "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", + "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", + "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", + "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", + "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", + "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", + "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", + "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", + "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", + "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", + "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", + "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", + "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", + "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", + "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", + "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", + "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", + "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", + "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", + "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", + "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", + "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", + "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", + "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", + "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", + "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", + "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", + "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", + "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", + "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", + "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", + "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", + "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", + "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", + "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", + "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", + "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", + "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", + "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", + "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", + "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", + "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", + "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", + "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", + "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", + "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", + "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", + "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", + "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", + "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", + "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", + "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", + "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50" + ], + "markers": "python_version >= '3.9'", + "version": "==3.0.2" }, "mccabe": { "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" ], - "version": "==0.6.1" + "markers": "python_version >= '3.6'", + "version": "==0.7.0" }, "mypy-extensions": { "hashes": [ @@ -446,11 +478,11 @@ }, "packaging": { "hashes": [ - "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", - "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" ], "markers": "python_version >= '3.8'", - "version": "==24.1" + "version": "==24.2" }, "pathspec": { "hashes": [ @@ -462,19 +494,19 @@ }, "platformdirs": { "hashes": [ - "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", - "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" ], "markers": "python_version >= '3.8'", - "version": "==4.2.2" + "version": "==4.3.6" }, "pluggy": { "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + "sha256:265a94bf44ca13662f12fcd1b074c14d4b269a712f051b6f644ef7e705d6735f", + "sha256:467f0219e89bb5061a8429c6fc5cf055fa3983a0e68e84a1d205046306b37d9e" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" + "version": "==1.0.0.dev0" }, "pre-commit": { "hashes": [ @@ -495,11 +527,11 @@ }, "pycodestyle": { "hashes": [ - "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", - "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" + "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", + "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.7.0" + "markers": "python_version >= '3.8'", + "version": "==2.12.1" }, "pycparser": { "hashes": [ @@ -511,11 +543,11 @@ }, "pyflakes": { "hashes": [ - "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", - "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db" + "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", + "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.3.1" + "markers": "python_version >= '3.8'", + "version": "==3.2.0" }, "pygithub": { "hashes": [ @@ -528,11 +560,11 @@ }, "pyjwt": { "hashes": [ - "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de", - "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320" + "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", + "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c" ], - "markers": "python_version >= '3.7'", - "version": "==2.8.0" + "markers": "python_version >= '3.8'", + "version": "==2.9.0" }, "pynacl": { "hashes": [ @@ -667,13 +699,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, - "testfixtures": { - "hashes": [ - "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84", - "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d" - ], - "version": "==6.18.5" - }, "text-unidecode": { "hashes": [ "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", @@ -689,14 +714,6 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, "tox": { "hashes": [ "sha256:67636634df6569e450c4bc18fdfd8b84d7903b3902d5c65416eb6735f3d4afb8", @@ -708,103 +725,98 @@ }, "types-python-dateutil": { "hashes": [ - "sha256:5d2f2e240b86905e40944dd787db6da9263f0deabef1076ddaed797351ec0202", - "sha256:6b8cb66d960771ce5ff974e9dd45e38facb81718cc1e208b10b1baccbfdbee3b" + "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d", + "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446" ], "markers": "python_version >= '3.8'", - "version": "==2.9.0.20240316" + "version": "==2.9.0.20241003" }, "urllib3": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" ], "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==2.2.3" }, "virtualenv": { "hashes": [ - "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c", - "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b" + "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba", + "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4" ], - "markers": "python_version >= '3.7'", - "version": "==20.26.2" + "markers": "python_version >= '3.8'", + "version": "==20.27.1" }, "wrapt": { "hashes": [ - "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc", - "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", - "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", - "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e", - "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca", - "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0", - "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb", - "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487", - "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40", - "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", - "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", - "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202", - "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41", - "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", - "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", - "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664", - "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", - "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", - "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00", - "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", - "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", - "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267", - "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", - "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966", - "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", - "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228", - "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72", - "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", - "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292", - "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0", - "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0", - "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", - "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c", - "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5", - "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f", - "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", - "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", - "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2", - "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593", - "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39", - "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", - "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf", - "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf", - "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", - "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c", - "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c", - "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f", - "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440", - "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465", - "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136", - "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b", - "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8", - "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", - "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8", - "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6", - "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e", - "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f", - "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c", - "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e", - "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", - "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2", - "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020", - "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35", - "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", - "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3", - "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537", - "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", - "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d", - "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a", - "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4" + "sha256:01b573dfc8fc3e2994a95cf90eb6570ddd55c52cf0d4b4b06761e96dafd5830a", + "sha256:026c845d7aa249b963d80d9dd6da64bdea6c77379dbf2a91080236cd1ea9bb8b", + "sha256:0409371198ee5e9e42a55549027f8a332e0395dcb2ce8973efd49c7348dfd86f", + "sha256:095d25f2411615101a909d4f5a16d02d4f139ed40ce65e1367d13818d8cfd54b", + "sha256:0e9ccf60bfce87ab06c20e9ed029755ce4656014773b5fb5660e283c2efbd319", + "sha256:10a22cb6f698f5e677405c7a8c095f52e4a9673923d4a628a0ad365c7e5f2f5b", + "sha256:124d50e65e6c4f2fdf6a09a47eed0935358f0c6d21553f32a8097cac0ba1d2e4", + "sha256:14eb6c11270129646557b74b2a1f721fa735ea52d46713710a0eb749c441cb0a", + "sha256:15f63f0d3de4705a2ac9642b600b9a50dbd90b9f3c862f619fc544fb76861447", + "sha256:3010faf2679814a53ed6a4e6a8051081d57e94db205acc305a77925fcd3a4f16", + "sha256:33116c20b2be5c1bb4984108e8fca2582d2545772b7ea4e12a88dbeb39550b5d", + "sha256:33b1b49b3ddd34ed1255b32db9e6549465785eb4746d14e1796ef944de6c17de", + "sha256:33c5dc8151376181107eb69812208b3aa108b929b390eb6fe902ec1c5379c794", + "sha256:396d9543f0cacecb673d303e2ea57d20a7b6fbde87e3506f5e71e293dba06d35", + "sha256:3b5c092cc8c105cebcda4ebb6399880d4167e65f68b1e3d65e4d9dba0e0d13d7", + "sha256:3b60f73fca01113fdef86810b9f6bd7d1b665f82c5c7516e865a4e990686e9cf", + "sha256:4afa4ef8a12374232967e8829acfa96abf011c486736d3fd7dd4d5dc33f7a1f7", + "sha256:516ef821b907ddd3b9480226ad5d503bcd8f256b2e68ce6b5e87a0d2904cce95", + "sha256:54da2aab97090764abf931f8d22b3b3c9a9a3480b14f803cf7deb4b4240fd627", + "sha256:54e94bc82e6b7094fd97ac6de427a16003180101ce38ec1e757e88525bbd0744", + "sha256:562efa31478b5e96934d933a02c2a24f524a924eca75b1a5fad3b1df1540c10f", + "sha256:665c5aea623f0e4603aa51804012ad9facef8bf7df6a11f9b8cc6ef0ab4b13ec", + "sha256:67b0a7ea101557446f9c3bee3d10933a2e8ae7bf854f368dc060e29b70823012", + "sha256:69bc5c72aea7b1de8aaa15da64aaaabbceed85f6d7e0ff2ee624cd393610e5b5", + "sha256:6a97fa860d8492779d6298084f359cabbae42dfb61a93a946ba556290942abea", + "sha256:7262fea0aa742c348d23b942014a82b7aef447d2e342295f92e618d9d48625fc", + "sha256:7578211a12156c399e063ffc4934ca5b1995bd15d37804c13ba3c57dfd293b22", + "sha256:7779c6ad9b9c228fc5701e163f099efc7e59eddfa3434994ed27013c42c4fbe3", + "sha256:7ec210dcd94833ed6f0105e481e68590deafadd0a0d102f9c07adcf3068e7472", + "sha256:851af61468f79be40f08f98cd20266f70ef27edf0edb0f0684ca35b3ffa6fc14", + "sha256:8ac35206ae402a84e1276664d58fdc0b34407cb36bbdbf696d1d233669920038", + "sha256:8b03ada4cbd78759ec297864b3d93005856f200dfd2271c349bfc253bb417f4e", + "sha256:8d021893becd9aca7145cfa2367ae0bc0714f61f04e81908d4aeb141e6274a25", + "sha256:8ed5bb099868a7314fbbdd5547e14975a2081893b492d77ce005641a1821141e", + "sha256:940152ae760fabe62c55a5903e177ecef2cf410f8cd4e45a610355effa86f475", + "sha256:9e21e3246f2120ed85de667ccda6f142e6a8eb8a4de1e77e6fb58ef6eed13860", + "sha256:9f46dd715242ba27683ac391b772f2a61c05a1e4c1df96bc5e7816c630b9d31b", + "sha256:9fcbf4cd542db188dc768a1e8b8869cf3ff81ed66a9cf7f42bf7ebaae6065f7e", + "sha256:a2649e3ef007f39fba0a8773a77c0d57746f27d8e838ac399c43b2080035a41f", + "sha256:a3a2c30e162816bf4cdf5adb54e2bc5da4bf0f75a0e6c6052d0676c7b23a65d1", + "sha256:a5ad032ddea5041a78d95b2726de397023cb0d2f3b72776d2084160057222f90", + "sha256:acbf6b956ddb7f2318f98b4b5b6549ee612d61a05015c92f848c4dda0b2f9b38", + "sha256:ace8c2e3d0519d4b48b7c183cb83093deaee959a2301212e350fd7ae7f8ee2c7", + "sha256:aec0b552295239a5f8bdf9a3f9f967e52c5f51823ac270e9b4f97aaee2660b02", + "sha256:b2f016359dc034c6c7abc695dc87f5665bf7d30c880bd3840f860c8d13b84bc5", + "sha256:c090cf67dd4f05e7f6d239e1f352a714c425b4ab1f33b3cf63ca044df52c2cfa", + "sha256:c536231a5e17c5606a6e49cfb4f24cf1429e76122592fcbc39e9b7b68b2b33c1", + "sha256:c566de3e7459cf76d1b3e34eaf053609163e72e5081207bcbe7eac53f1f567b7", + "sha256:caf8e9c47c9601e9245f978da019406a23a7c8e227482ae4147912c75c597207", + "sha256:cb1490293318c393502289e0703090caf13bb61ebf0c26e4b7f60de2d0159afa", + "sha256:cc4605efda72650937ebd75cdb97fba172dd37bb33781110541627c7948431c1", + "sha256:cc80d159392b9338d2ffab07db4e6b5cf64f937876e9c6f1cca83f8df7f2ef0f", + "sha256:cd9f4439dd1dad406bc079b4ee79467737813474d3479ea4d838d1446479f34b", + "sha256:cf8cdaa2e9b628fec30c5209bb471359742643e4793aa03b51093563562089d6", + "sha256:d06e731ce5a939963bf305336229cc55c9b513008d0427753b8b835a6270dc95", + "sha256:d2864ab0b77fd6a70f8010f97bead48fcb509218d5c838d9021615ccba01ab48", + "sha256:d342abd7678925b92a3289f79a4947b2eb77f4703e7fa465ee1adb2463adf251", + "sha256:d59fa7a20617fab6cd2231c77a1fb7b77479126876bf9f8af6f01f259d9fe8e1", + "sha256:d7c4177c5ac8e79e7b436a9410ed24426e5fedb42792749e46b398808e5efe84", + "sha256:d87026d941806e99d0f4d894e5d173fcc9ac4cb161abd62fc70b2607a2076113", + "sha256:e451c246c660850d3ca77cd92369a6a485fc694928c544a6528dd5430352bad8", + "sha256:f12e6f6fc3daf45574938f81df4576814995739f98c92b377b410d7284942e76", + "sha256:f2702e803a12e363db870faa2b86605809314775588f1fac9034976f74ce083c", + "sha256:f9e244272230f0a907c5171ce1d29683505cbd507d42471f42dff26433633c4e", + "sha256:fbf958a82876f7158ce3547f7bd8618e65c61e1ee39d0d310247a548e1ff6682" ], - "markers": "python_version >= '3.6'", - "version": "==1.16.0" + "markers": "python_version >= '3.8'", + "version": "==1.17.0rc1" } }, "develop": {} diff --git a/README.md b/README.md index 5de9f1cb4..13a8a3985 100644 --- a/README.md +++ b/README.md @@ -6,40 +6,41 @@ A production-ready Django SPA app on Heroku in 20-min or less! ## Quick Start -First, get cookiecutter, as detailed in the [official documentation](https://cookiecutter.readthedocs.io/en/stable/installation.html#install-cookiecutter). +First, get `pipx` for your system, if you don't already have it [installing pipx](https://pipx.pypa.io/stable/installation/#installing-pipx). -Now run it against this repo: +Adn run the following command: ```bash -cookiecutter git@github.com:thinknimble/tn-spa-bootstrapper.git +pipx install cookiecutter +pipx run cookiecutter gh:thinknimble/tn-spa-bootstrapper ``` ## Features See: [Maintained Foundation fork] - - For Django 3.1 - - Uses Python 3.10 by default - - Renders Django projects with 100% starting test coverage - - Secure by default. We believe in SSL. - - Optimized development and production settings - - Comes with custom user model ready to go - - Optional basic ASGI setup for Websockets - - Optional basic Django channel setup for Websockets - - Optional client side applications Vue or React - - Send emails using [Mailgun] by default or Amazon SES if AWS is selected cloud provider. - - Media storage using Amazon S3 or Google Cloud Storage - - [Procfile] for deploying to Heroku - - Run tests with unittest or pytest - - Default integration with [pre-commit] for identifying simple issues before submission to code review - - Integration with [Rollbar] for error logging +- For Django 4.2.\* +- Uses Python 3.12 by default +- Renders Django projects with 100% starting test coverage +- Secure by default. We believe in SSL. +- Optimized development and production settings +- Comes with custom user model ready to go +- Optional basic ASGI setup for Websockets +- Optional basic Django channel setup for Websockets +- Optional client side applications Vue or React +- Send emails using [Mailgun] by default or Amazon SES if AWS is selected cloud provider. +- Media storage using Amazon S3 or Google Cloud Storage +- [Procfile] for deploying to Heroku +- Run tests with unittest or pytest +- Default integration with [pre-commit] for identifying simple issues before submission to code review +- Integration with [Rollbar] for error logging ## Optional Integrations These features can be enabled after initial project setup: - - Serve static files from Amazon S3 or Whitenoise - - Integration with [MailHog] for local email testing +- Serve static files from Amazon S3 or Whitenoise +- Integration with [MailHog] for local email testing ## Usage @@ -66,22 +67,22 @@ Answer the prompts with your own desired options. For example: 3 - None Choose from 1, 2, 3 [1]: 1 Error: "my_project" directory already exists - william@Williams-MacBook-Pro thinknimble % rm -rf my_project + william@Williams-MacBook-Pro thinknimble % rm -rf my_project william@Williams-MacBook-Pro thinknimble % cookiecutter git@github.com:thinknimble/tn-spa-cookiecutter.git --checkout cleanup - You've downloaded /Users/william/.cookiecutters/tn-spa-cookiecutter before. Is it okay to delete and re-download it? [yes]: - project_name [My Project]: - author_name [ThinkNimble]: - email [hello@thinknimble.com]: - project_slug [my_project]: + You've downloaded /Users/william/.cookiecutters/tn-spa-cookiecutter before. Is it okay to delete and re-download it? [yes]: + project_name [My Project]: + author_name [ThinkNimble]: + email [hello@thinknimble.com]: + project_slug [my_project]: Select mail_service: 1 - Mailgun 2 - Amazon SES 3 - Custom SMTP - Choose from 1, 2, 3 [1]: + Choose from 1, 2, 3 [1]: Select client_app: 1 - Vue3 2 - None - Choose from 1, 2 [1]: + Choose from 1, 2 [1]: Create a git repo and push it there:: @@ -90,7 +91,7 @@ git init git add . git commit -m "first awesome commit" git remote set-url origin git@github.com:thinknimble/the-rock.git -git push -u origin main +git push -u origin main ``` Now take a look at your repo. Don't forget to carefully look at the generated README. Awesome, right? diff --git a/cookiecutter.json b/cookiecutter.json index 2c44fc413..ccf24a1db 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -4,7 +4,6 @@ "email": "hello@thinknimble.com", "project_slug": "{{ cookiecutter.project_name.lower()|replace(' ', '_')|replace('-', '_')|replace('.', '_')|trim() }}", "_extensions": [ - "jinja2_time.TimeExtension", "cookiecutter.extensions.RandomStringExtension" ], "_copy_without_render": [ @@ -12,10 +11,11 @@ "clients/web/vue3/src/components", "clients/web/react/src/components", "clients/web/react/src/pages/app-or-auth.tsx", - "clients/web/react/src/pages/home.tsx", "clients/web/react/src/pages/index.ts", "clients/web/react/src/pages/layout.tsx", - "*/swagger-ui.html" + "*/swagger-ui.html", + "clients/mobile/react-native/src/screens/settings/edit-profile.tsx", + "clients/mobile/react-native/src/screens/settings/main-settings.tsx" ], "mail_service": [ "Mailgun", diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 015d363e9..f6f37e49a 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -1,5 +1,5 @@ import secrets -from os import remove, rename +from os import remove from os.path import exists, join from shutil import copy2, move, rmtree @@ -76,15 +76,22 @@ def move_web_client_to_root(client): rmtree("client") move(join(web_clients_path, client), join("client")) rmtree(join(web_clients_path)) - env_path = join("client", ".env.local.example") - if exists(env_path): - rename(env_path, join("client", ".env.local")) def remove_mobile_client_files(client): rmtree(join(mobile_clients_path, client)) +def remove_special_mobile_files(): + file_names = [join("scripts/setup_mobile_config.sh")] + directories = [join("resources")] + for file in file_names: + if exists(file): + remove(file) + for directory in directories: + rmtree(directory) + + def move_mobile_client_to_root(client): if exists("mobile"): rmtree("mobile") @@ -122,15 +129,12 @@ def set_keys_in_envs(django_secret, postgres_secret): cookie_cutter_settings_path = join("app.json") postgres_init_file = join("scripts/init-db.sh") set_flag(env_file_path, "!!!DJANGO_SECRET_KEY!!!", django_secret) + set_flag(env_file_path, "!!!PLAYWRIGHT_SECRET_KEY!!!", django_secret) set_flag(pull_request_template_path, "!!!DJANGO_SECRET_KEY!!!", django_secret) set_flag(cookie_cutter_settings_path, "!!!DJANGO_SECRET_KEY!!!", django_secret) set_flag(env_file_path, "!!!POSTGRES_PASSWORD!!!", postgres_secret) set_flag(postgres_init_file, "!!!POSTGRES_PASSWORD!!!", postgres_secret) copy2(env_file_path, join(".env")) - cypress_example_file_dir = join(web_clients_path, "react") - cypress_example_file = join(cypress_example_file_dir, "cypress.example.env.json") - set_flag(cypress_example_file, "!!!POSTGRES_PASSWORD!!!", postgres_secret) - copy2(cypress_example_file, join(cypress_example_file_dir, "cypress.env.json")) def get_secrets(): @@ -171,6 +175,7 @@ def main(): move_mobile_client_to_root("react-native") else: remove_expo_yaml_files() + remove_special_mobile_files() clean_up_clients_folder() diff --git a/resources/app.config.vars.txt b/resources/app.config.vars.txt new file mode 100644 index 000000000..f1d3456c0 --- /dev/null +++ b/resources/app.config.vars.txt @@ -0,0 +1,8 @@ + +REPLACE_WITH_EXPO_APP_NAME=tn mobile bootstrapper +REPLACE_WITH_EXPO_APP_SLUG=tn-sample-app +REPLACE_WITH_EXPO_OWNER=thinknimble-bootstrapper +REPLACE_WITH_EXPO_APP_ID=ec1b86e2-2582-48cf-8a7a-c6d2772ba4f2 +REPLACE_WITH_SENTRY_ORG=tn-bootstrapper +REPLACE_WITH_IOS_BUNDLE_ID=org.thinknimble.expo.bootstrapper +REPLACE_WITH_ANDROID_PACKAGE_ID=com.example.app diff --git a/resources/eas-bs.json b/resources/eas-bs.json deleted file mode 100644 index ccb2daee3..000000000 --- a/resources/eas-bs.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "cli": { - "version": ">= 2.4.1", - "appVersionSource": "remote" - }, - "build": { - "development": { - "developmentClient": true, - "distribution": "internal", - "channel": "development" - }, - "staging": { - "distribution": "internal", - "channel": "staging", - "env": { - "BACKEND_SERVER_URL": "https://tn-spa-bootstrapper-production.herokuapp.com/", - "BUILD_ENV": "staging", - "ROLLBAR_ACCESS_TOKEN": "1a19e5da05b2435b802d5a81aba2bbd7", - "SENTRY_PROJECT_NAME": "tn-staging", - "SENTRY_DSN": "https://a7cea97f07ac42fa9e28800b037997c9@o4504899535962112.ingest.sentry.io/4504906332897280" - } - }, - "production": { - "channel": "production", - "autoIncrement": true, - "env": { - "BACKEND_SERVER_URL": "https://tn-spa-bootstrapper-staging.herokuapp.com/", - "BUILD_ENV": "production", - "ROLLBAR_ACCESS_TOKEN": "e6246274f1f2411990f0cd1c7b99b072", - "SENTRY_PROJECT_NAME": "tn-prod", - "SENTRY_DSN": "https://df747bb2e58d4f178f433f322bd41026@o4504899535962112.ingest.sentry.io/4504906701733888" - } - } - }, - "submit": { - "production": { - "ios": { - "appName": "TN Bootsrapper RN", - "appleId": "pari@thinknimble.com", - "appleTeamId": "6BNA6HFF6B", - "ascAppId": "12023" - } - } - } - } \ No newline at end of file diff --git a/resources/eas.vars.txt b/resources/eas.vars.txt new file mode 100644 index 000000000..038e4e8eb --- /dev/null +++ b/resources/eas.vars.txt @@ -0,0 +1,18 @@ +REPLACE_WITH_LOCAL_BACKEND_SERVER_URL=https://tn-spa-bootstrapper-production.herokuapp.com +REPLACE_WITH_STAGING_BACKEND_SERVER_URL=https://tn-spa-bootstrapper-production.herokuapp.com +REPLACE_WITH_PRODUCTION_BACKEND_SERVER_URL=https://tn-spa-bootstrapper-production.herokuapp.com +REPLACE_WITH_REVIEW_APP_BACKEND_SERVER_URL=https://tn-spa-bootstrapper-production.herokuapp.com +REPLACE_WITH_LOCAL_ROLLBAR_TOKEN=1a19e5da05b2435b802d5a81aba2bbd7 +REPLACE_WITH_STAGING_ROLLBAR_TOKEN=1a19e5da05b2435b802d5a81aba2bbd7 +REPLACE_WITH_PRODUCTION_ROLLBAR_TOKEN=1a19e5da05b2435b802d5a81aba2bbd7 +REPLACE_WITH_REVIEW_APP_ROLLBAR_TOKEN=1a19e5da05b2435b802d5a81aba2bbd7 +REPLACE_WITH_REVIEW_APP_SENTRY_DSN=https://a7cea97f07ac42fa9e28800b037997c9@o4504899535962112.ingest.sentry.io/4504906332897280 +REPLACE_WITH_LOCAL_SENTRY_DSN=https://a7cea97f07ac42fa9e28800b037997c9@o4504899535962112.ingest.sentry.io/4504906332897280 +REPLACE_WITH_STAGING_SENTRY_DSN=https://a7cea97f07ac42fa9e28800b037997c9@o4504899535962112.ingest.sentry.io/4504906332897280 +REPLACE_WITH_PRODUCTION_SENTRY_DSN=https://a7cea97f07ac42fa9e28800b037997c9@o4504899535962112.ingest.sentry.io/4504906332897280 +REPLACE_WITH_APPSTORE_CONNECT_APP_NAME=TN Bootsrapper RN +REPLACE_WITH_APPLE_ID_EMAIL_SIGNING_APP=pari@thinknimble.com +REPLACE_WITH_APPLESTORE_CONNECT_ID=6446805695 +REPLACE_WITH_APPLE_TEAM_ID=6BNA6HFF6B +REPLACE_WITH_REVIEW_APP_SENTRY_PROJECT_NAME=tn-bootsrapper-rn + diff --git a/scripts/vue_or_react.sh b/scripts/vue_or_react.sh index 7fc7c14c0..eb888ad7d 100755 --- a/scripts/vue_or_react.sh +++ b/scripts/vue_or_react.sh @@ -14,7 +14,7 @@ else fi if [ "$rn_count" != 0 ]; then - cp -r resources/ {{cookiecutter.project_slug}}/ + cp -r resources/ {{cookiecutter.project_slug}}/resources/ sed -i.bak 's/include_mobile: "n"/include_mobile: "y"/' $config_file_path fi diff --git a/setup.py b/setup.py index b154405bd..d05004c01 100644 --- a/setup.py +++ b/setup.py @@ -34,14 +34,14 @@ classifiers=[ "Development Status :: 1 - Beta", "Environment :: Console", - "Framework :: Django :: 3.0", + "Framework :: Django :: 4.2", "Client Framework :: Vue", "Intended Audience :: Thinknimble Developers", "Natural Language :: English", "License :: Proprietary License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development", ], diff --git a/{{cookiecutter.project_slug}}/.env.example b/{{cookiecutter.project_slug}}/.env.example index 78bef4911..171b47ec8 100644 --- a/{{cookiecutter.project_slug}}/.env.example +++ b/{{cookiecutter.project_slug}}/.env.example @@ -151,5 +151,5 @@ DEFAULT_FROM_EMAIL='{{ cookiecutter.project_name }} > $GITHUB_OUTPUT else - echo "BUILD_MOBILE_APP=$(git diff-tree --no-commit-id --name-only -r {{ "${{ github.sha }}" }} | xargs | grep mobile/ | wc -l)" >> $GITHUB_OUTPUT + echo "BUILD_MOBILE_APP=$(git diff-tree --no-commit-id --name-only -r {{ "${{ github.sha }}" }} | xargs | grep mobile/ | wc -l)" >> $GITHUB_OUTPUT + fi printouts: runs-on: ubuntu-latest diff --git a/{{cookiecutter.project_slug}}/.github/workflows/expo-pr.yml b/{{cookiecutter.project_slug}}/.github/workflows/expo-pr.yml index fd7dff43f..0e7f0a6b6 100644 --- a/{{cookiecutter.project_slug}}/.github/workflows/expo-pr.yml +++ b/{{cookiecutter.project_slug}}/.github/workflows/expo-pr.yml @@ -50,4 +50,3 @@ jobs: env: EXPO_PUBLIC_BACKEND_SERVER_URL: {{ "${{ github.event.deployment_status.environment_url }}" }} EXPO_PUBLIC_ROLLBAR_ACCESS_TOKEN: "" - diff --git a/{{cookiecutter.project_slug}}/.github/workflows/linting.yml b/{{cookiecutter.project_slug}}/.github/workflows/linting.yml index d6d8b88cd..63cd16b2e 100644 --- a/{{cookiecutter.project_slug}}/.github/workflows/linting.yml +++ b/{{cookiecutter.project_slug}}/.github/workflows/linting.yml @@ -8,7 +8,7 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' cache: 'pipenv' - run: pipenv install --dev --deploy - run: pipenv run black --check server @@ -19,7 +19,7 @@ jobs: - run: pipx install pipenv - uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' cache: 'pipenv' - run: pipenv install --dev --deploy - run: pipenv run flake8 server diff --git a/{{cookiecutter.project_slug}}/.github/workflows/playwright.yml b/{{cookiecutter.project_slug}}/.github/workflows/playwright.yml new file mode 100644 index 000000000..e771ec7d8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/playwright.yml @@ -0,0 +1,31 @@ +name: Playwright Tests +on: + deployment_status: +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + if: github.event.deployment_status.state == 'success' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install dependencies + working-directory: ./client + run: npm install + - name: Install Playwright + working-directory: ./client + run: npx playwright install --with-deps + - name: Run Playwright tests + working-directory: ./client + run: npx playwright test --reporter=html + env: + PLAYWRIGHT_TEST_BASE_URL: {{ "${{ github.event.deployment_status.environment_url }}" }} + PLAYWRIGHT_TEST_USER_PASS: {{ "${{ secrets.PLAYWRIGHT_TEST_USER_PASS }}" }} + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: ./client/playwright-report/ + retention-days: 30 diff --git a/{{cookiecutter.project_slug}}/.gitignore b/{{cookiecutter.project_slug}}/.gitignore index 613140fae..95533f063 100644 --- a/{{cookiecutter.project_slug}}/.gitignore +++ b/{{cookiecutter.project_slug}}/.gitignore @@ -31,11 +31,6 @@ wheels/ .installed.cfg *.egg -# Ignore Cypress environment variables & media -client/cypress.env.json -client/tests/e2e/screenshots/* -client/tests/e2e/videos/* - # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. @@ -76,6 +71,7 @@ celerybeat-schedule # Environments .env* +!.env.local.example !.env.example .venv env/ diff --git a/{{cookiecutter.project_slug}}/Pipfile b/{{cookiecutter.project_slug}}/Pipfile index 18c5ee73a..db4167857 100644 --- a/{{cookiecutter.project_slug}}/Pipfile +++ b/{{cookiecutter.project_slug}}/Pipfile @@ -7,7 +7,6 @@ name = "pypi" Django = "==4.2.*" django-extensions = "==3.2.3" django-filter = "==2.4.0" -django-nose = "==1.4.7" dj-rest-auth = "*" dj-database-url = "==0.5.0" django-storages = "==1.11.1" # https://github.com/jschneier/django-storages @@ -28,12 +27,12 @@ Pillow = "==9.0" django-currentuser = "==0.7.0" drf-nested-routers = "==0.93.3" django-anymail = "==8.4" # https://github.com/anymail/django-anymail -rollbar = "==0.16.2" +rollbar = "==1.0.0" premailer = "*" drf-spectacular = "*" [requires] -python_version = "3.10" +python_version = "3.12" [dev-packages] # Code quality diff --git a/{{cookiecutter.project_slug}}/Procfile b/{{cookiecutter.project_slug}}/Procfile index 5a090a7c8..128c14f4d 100644 --- a/{{cookiecutter.project_slug}}/Procfile +++ b/{{cookiecutter.project_slug}}/Procfile @@ -1,19 +1,5 @@ -# -# Define the 'web' process to be run on Heroku -# +# Main webapp process web: gunicorn {{ cookiecutter.project_slug }}.wsgi --chdir=server --log-file - -# -# This is not mandatory for all projects, as our process currently utilizes long-term staging -# servers, and automatically running migrations on this staging server could cause issues when -# different feature branches have different migrations. The aim is to switch to a model where our -# staging servers have a smaller lifetime, and we would not run into the above issue. -# To make this switch the following problems need to be solved. -# 1. Setting up human-readable URLS for the short-lived staging servers. -# 2. Fixtures for common test data (ex: test users, common app-specific entities) -# 3. Up-to-date build numbers shown in app (ideally auto-generated at build-and-deploy time) -# -# Comment the line below to disable migrations automatically after a Heroku push. +# Update DB schema for any changes release: python server/manage.py migrate --noinput - - diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index 275dbc7fc..1cf2f4886 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -5,34 +5,44 @@ ## Setup ### Docker + If this is your first time... + 1. [Install Docker](https://www.docker.com/) 1. Run `pipenv lock` to generate a Pipfile.lock 1. Run `cd client && npm install` so you have node_modules available outside of Docker 1. Back in the root directory, run `make build` 1. `make run` to start the app 1. If the DB is new, run `make create-test-data` - 1. SuperUser `admin@thinknimble.com` with credentials from your `.env` - 1. User `cypress@thinknimble.com` with credentials from your `.env` is used by the Cypress - tests + 1. SuperUser `admin@thinknimble.com` with credentials from your `.env` + 1. User `playwright@thinknimble.com` with credentials from your `.env` is used by the Playwright + tests 1. View other available scripts/commands with `make commands` 1. `localhost:8080` to view the app. 1. `localhost:8000/staff/` to log into the Django admin 1. `localhost:8000/api/docs/` to view backend API endpoints available for frontend development - ### Backend + If not using Docker... See the [backend README](server/README.md) ### Frontend + If not using Docker... See the [frontend README](client/README.md) - ## Testing & Linting Locally + 1. `pipenv install --dev` 1. `pipenv run pytest server` 1. `pipenv run black server` 1. `pipenv run isort server --diff` (shows you what isort is expecting) -1. `npm run cypress` + +## Frontend E2E Testing with Playwright + +1. `cd client` +1. `npx playwright install` - Installs browser driver +1. `npx playwright install-deps` - Install system-level dependencies +1. `npx playwright test` +1. `npx playwright codegen localhost:8080` - Generate your tests through manual testing diff --git a/{{cookiecutter.project_slug}}/app.json b/{{cookiecutter.project_slug}}/app.json index 9a8281135..c055e0924 100644 --- a/{{cookiecutter.project_slug}}/app.json +++ b/{{cookiecutter.project_slug}}/app.json @@ -22,23 +22,17 @@ "DJANGO_SUPERUSER_PASSWORD": { "value": "!!!DJANGO_SECRET_KEY!!!" }, - "CYPRESS_TEST_USER_PASS": { + "PLAYWRIGHT_TEST_USER_PASS": { "value": "!!!DJANGO_SECRET_KEY!!!" }, "SECRET_KEY": { "generator": "secret" } }, - "addons": [ - "heroku-postgresql:standard-0", - "papertrail:choklad" - ], + "addons": ["heroku-postgresql:standard-0", "papertrail:choklad"], "environments": { "review": { - "addons": [ - "heroku-postgresql:essential-0", - "papertrail:choklad" - ] + "addons": ["heroku-postgresql:essential-0", "papertrail:choklad"] } }, "buildpacks": [ diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/.eslintrc.js b/{{cookiecutter.project_slug}}/clients/mobile/react-native/.eslintrc.js index cf30932f9..3f88c3f61 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/.eslintrc.js +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/.eslintrc.js @@ -11,6 +11,8 @@ module.exports = { 'prettier/prettier': 'off', 'react/react-in-jsx-scope': 'off', '@typescript-eslint/no-unused-vars': 'warn', + 'react-native/no-raw-text': 2, + 'react-native/no-unused-styles': 2, }, ignorePatterns: ['tailwind.config.js', 'metro.config.js'], } diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/App.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/App.tsx index eb4910bda..872edbc8a 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/App.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/App.tsx @@ -1,19 +1,23 @@ -import { AppRoot } from '@screens/routes' +import '@components/sheets/register-sheets' +import { AppRoot, getNavio } from '@screens/routes' import * as Sentry from '@sentry/react-native' import { useAuth } from '@stores/auth' +import { navioAtom } from '@stores/navigation' import { QueryClientProvider } from '@tanstack/react-query' import { customFonts } from '@utils/fonts' import { queryClient } from '@utils/query-client' import { initSentry } from '@utils/sentry' import 'expo-dev-client' -import { loadAsync } from 'expo-font' import { setNotificationHandler } from 'expo-notifications' import * as SplashScreen from 'expo-splash-screen' import { StatusBar } from 'expo-status-bar' +import { useSetAtom } from 'jotai' import React, { useCallback, useEffect, useState } from 'react' import { flushSync } from 'react-dom' import { LogBox, StyleSheet } from 'react-native' +import { SheetProvider } from 'react-native-actions-sheet' import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { useFonts } from 'expo-font' import './global.css' LogBox.ignoreLogs(['Require']) @@ -39,26 +43,31 @@ SplashScreen.preventAutoHideAsync() export default Sentry.wrap((): JSX.Element => { const [ready, setReady] = useState(false) const hasLocalStorageHydratedState = useAuth.use.hasHydrated() + const setNavio = useSetAtom(navioAtom) + const [loaded, error] = useFonts(customFonts) const start = useCallback(async () => { - await loadAsync(customFonts) await hasLocalStorageHydratedState await SplashScreen.hideAsync() + setNavio(getNavio()) flushSync(() => { setReady(true) }) - }, [hasLocalStorageHydratedState]) + }, [hasLocalStorageHydratedState, setNavio]) useEffect(() => { + if (!loaded) return start() - }, [start]) + }, [loaded, start]) if (!ready) return <> return ( - - + + + + ) diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/Config.js b/{{cookiecutter.project_slug}}/clients/mobile/react-native/Config.js index 65194934f..8f2e3b6e9 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/Config.js +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/Config.js @@ -1,4 +1,5 @@ import Constants, { ExecutionEnvironment } from 'expo-constants' +import * as Updates from 'expo-updates'; import { Platform } from 'react-native' import Logger from './logger' @@ -11,19 +12,58 @@ const { backendServerUrl, rollbarAccessToken, sentryDSN } = Constants?.expoConfi const isExpoGo = Constants.executionEnvironment === ExecutionEnvironment.StoreClient const isAndroid = Platform.OS === 'android' + +const config = { + 'expo-go':{ + backendServerUrl: BACKEND_SERVER_URL, + rollbarAccessToken: isAndroid ? null : ROLLBAR_ACCESS_TOKEN, + sentryDSN: SENTRY_DSN, + + }, + 'expo_build':{ + 'staging':{ + backendServerUrl: backendServerUrl || BACKEND_SERVER_URL || 'https://{{ cookiecutter.project_slug }}-staging.herokuapp.com', + rollbarAccessToken: isAndroid ? null : rollbarAccessToken || ROLLBAR_ACCESS_TOKEN || "", + sentryDSN: sentryDSN || SENTRY_DSN || "", + }, + 'production':{ + backendServerUrl: backendServerUrl || BACKEND_SERVER_URL || 'https://{{ cookiecutter.project_slug }}-staging.herokuapp.com', + rollbarAccessToken: isAndroid ? null : rollbarAccessToken || ROLLBAR_ACCESS_TOKEN || "", + sentryDSN: sentryDSN || SENTRY_DSN || "", + } + + } + +} + + const ENV = () => { if (!isExpoGo) { - let rollbarToken = isAndroid ? undefined : rollbarAccessToken - const logger = new Logger(rollbarToken).logger + /** + * + * Temporary manual hack to set variables, due to an issue we are facing with expo-updates + * Pari Baker + * 2024-05-08 + */ + if (Updates.channel === 'staging') { + const logger = new Logger(config.expo_build.staging.rollbarAccessToken).logger + return {...config.expo_build.staging, logger} + }else if(Updates.channel === 'production'){ + const logger = new Logger(config.expo_build.production.rollbarAccessToken).logger + return {...config.expo_build.production, logger} + } + return { backendServerUrl, logger, sentryDSN, + isExpoGo, } } - let rollbarToken = isAndroid ? undefined : ROLLBAR_ACCESS_TOKEN - const logger = new Logger(rollbarToken).logger + + const logger = new Logger(config['expo-go'].rollbarAccessToken).logger + return { backendServerUrl: BACKEND_SERVER_URL, logger, diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/README.md b/{{cookiecutter.project_slug}}/clients/mobile/react-native/README.md index 3d90ab669..e9069cf94 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/README.md +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/README.md @@ -1,50 +1,60 @@ ## Getting Started -## General +This app is bootstrapped with the TN-Bootsrapper, it uses Expo as a wrapper framework around React Native. Install and run with **Node v20**. -This app is bootstrapped with the TN-Bootsrapper, it uses Expo as a wrapper framework around React Native +## Installing and Running -## Configuration and Bootstrapping +FIRST: You must follow ALL of the "Configuration" steps below. Once configured, you can install the NodeJS dependencies and run the app. -After running the bootsrapper a mobile directory is created the following steps are needed to run and deploy the app +```bash +cd mobile +node install --include=dev # Install NodeJS dependencies +npm run start # Source env variables and run dev server +``` -## Running the app locally -(Assuming you have already set up the app for the first time) +Alternatively, if you want to use expo run command: -User `npm run start` to run the app and source the env variables +```bash +source .env && npx expo start +``` -alternatively if you want to use expo run command +## Configuration -`source .env && SENTRY_PROJECT=${SENTRY_PROJECT_NAME} npx expo start` +After running the bootsrapper a mobile directory is created the following steps are needed to run and deploy the app -**When running the app locally and working against a local backend you will need to use a proxy** +### First, set up an Expo account -1. Download and install ngrok -2. Set up ngrok auth token (request an account from William Huster) -3. run ngrok `~/.ngrok http 8000 --subdomain ` -4. add your new subomain to the `.env` IN MOBILE DIR as BACKEND_DEV_SERVER -5. append your new subdomain to the `.env` in ROOT DIR to ALLOW_HOSTS e.g `ALLOW_HOSTS=[localhost:8080,.ngrok.io]` - +Go to [expo.dev](https://expo.dev/), and set up an Expo organization. +Select "Access Tokens" from the left sidebar: -### Set up external dependencies +1. Create an Expo Robot named, e.g. `CI/CD` +2. Click "+ Creat Token" to create an API key for the robot named e.g. `GH_ACTIONS` +3. Now go to your GitHub repo settings. Create a Repository Secret with the name `EXPO_TOKEN`, paste the API key you generated as the value, and save it. +4. **Create an EAS Project** -#### Expo +Back in Expo, click on "Projects" in the sidebar and create a new project. Give it the same name as your app. -Set up an expo organization -Generate an expo robot and api key -Set the env secret `EXPO_TOKEN` in GH secrets for the pipeline -Set the `SENTRY_AUTH_TOKEN` in the Expo secrets see (Error Logging & Crash Analytics) +You will then be presented with these commands. Copy and run them in the `./mobile` directory: -#### Error Logging and Crash Analytics +```bash +npm install --global eas-cli +eas init --id {eas_uuid} +``` + +NOTE: This may require setting up Rollbar and Sentry first. If so, follow the instructions below and then come back to this. + +### Set Up Error Logging and Crash Analytics -Set up a rollbar instance (if using heroku 1 app in the production environment is recommended) +Set up a [Rollbar](https://rollbar.com/) instance. We recommend setting up a single Heroku Rollbar add-on instance that is attached to the production environment. -Create a project in your rollbar account for each project and retrieve the `post_client_token` +In your Rollbar account, create a project for each environment and retrieve the `post_client_token`. -Set up sentry for crash analytics and additional error logs (We use sentry because it is pre-built to integrate with expo) +Set up [Sentry](https://sentry.io/) for crash analytics and additional error logs. We use Sentry because it is pre-built to integrate with Expo. -Create a sentry account and set up the projects for the various environments (this can also be added to the prod instance on heroku) +Create a Sentry account and set up the projects for the various environments. This can also be attached to the prod instance on Heroku. + +Then go to `Settings` > `Developer Settings` > `Auth Tokens` and create a new token that you'll use in `Expo` Retrieve: @@ -52,14 +62,31 @@ Retrieve: 2. Sentry DSN for each project 3. project-name -### Environment Variables +You should set the `SENTRY_AUTH_TOKEN` in Expo under `Secrets`. + +### Set Environment Variables For local run set environment variables in .env file (from [.env.example](./.env.example)) -For builds set env variables in eas.json -### Eas Project Configuration +For builds, environment variables should be set in [eas.json](./eas.json) + +#### Use the helper script to enter variables + +You can fill in `eas.json` and `app.config.js` by hand, but we have also written a script to make it easier to quickly update those files with your variable values. + +Fill in [eas.vars.template.txt](./resources/eas.vars.template.txt) and [app.config.vars.template.txt](./resources/app.config.vars.template.txt). + +Then run these commands using the `setup_mobile_config.sh` helper script: -in [app.config.js](./app.config.js) set the confiuration variables +```bash +. scripts/setup_mobile_config.sh eas.json /resources/eas.vars.txt + +. scripts/setup_mobile_config.sh app.config.js /resources/app.config.vars.txt +``` + +### EAS Project Configuration + +In [app.config.js](./app.config.js) set the configuration variables - owner: this should match the organization in expo - slug: this should match the slug in expo @@ -68,36 +95,44 @@ in [app.config.js](./app.config.js) set the confiuration variables - ios: bundleIdentifier: this should be created in the apple developer account - android: package: this should be created in the google play developer console -To configure non-inetractive builds for the CI/CD pipeline you must run BUT you will be doing that after the next step: +To configure non-interactive builds for the CI/CD pipeline you must run BUT you will be doing that after the next step: `eas credentials` -The recommended approach for managing credentials is through expo, in the selections you should see this as an option +The recommended approach for managing credentials is through Expo, in the selections you should see this as an option -| Build Credentials: Manage everything needed to build your project - | All: Set up all the required credentials to build your project +| Build Credentials: Manage everything needed to build your project +| All: Set up all the required credentials to build your project +### Connecting to a Backend in Development +Since Expo runs on a separate device, it cannot reach localhost on your computer, so you must use your computer's LAN **IP address** instead of localhost. +OR, you can use a web proxy like [ngrok](https://ngrok.com/): -**Apple** +1. Download and install ngrok +2. Set up ngrok auth token (request an account from William Huster) +3. run ngrok `~/.ngrok http 8000 --subdomain ` +4. add your new subomain to the `.env` IN MOBILE DIR as BACKEND_DEV_SERVER +5. append your new subdomain to the `.env` in ROOT DIR to ALLOW_HOSTS e.g `ALLOW_HOSTS=[localhost:8080,.ngrok.io]` + +## Set Up for the Apple App Store Head over to the (apple developer account)[https://developer.apple.com/account/resources/identifiers/bundleId/add/bundle] and set up a new bundle identifier <-- Only set up the bundle identifier not a complete app yet `eas credentials` -| Build Credentials: Manage everything needed to build your project - | All: Set up all the required credentials to build your project +| Build Credentials: Manage everything needed to build your project +| All: Set up all the required credentials to build your project Return to the menu and also set up AppStore Connect API Key | App Store Connect: Manage your API Key -If you need Push Notifications as well +If you need Push Notifications as well | Push Notifications: Manage your Apple Push Notifications Key - Configure the submit environment in the [eas.json](./eas.json) - ascAppId: this is a random uid that you will set when creating the app @@ -106,7 +141,7 @@ For internal builds to pass you must first register at least one testing device `eas device:create` -Select the option for URL and send the URL to each user who wants to test a build. +Select the option for URL and send the URL to each user who wants to test a build. Recreate the provisioning profile <- **this step is required in order for the user to be able to install the app** @@ -114,11 +149,10 @@ Recreate the provisioning profile <- **this step is required in order for the us Rebuild the app <- **this step is required in order for the user to be able to install the app** - You must run a first time production build to set up appstore connect keys to be managed by Expo +## Set Up for the Google Play Store -**Google** There is no required configuration for google in the eas.json however you must build and upload the apk for the first time before being able to automate. ### Deployments, Environments & Submissions @@ -131,12 +165,12 @@ There is no required configuration for google in the eas.json however you must b **Local** -*expo go* +_expo go_ To set env variables locally you may use the .env file and import the variables using `@env` eg in [Config.js](./Config.js), these variables are sourced in the npm run script If you need to use environment variables in the app.config.js you must declare the variables in the npm run script as well as the expo run occurs in a separate process -*development build* -the variables for this environment are set up in the eas.json under the development profile +_development build_ +the variables for this environment are set up in the eas.json under the development profile **Staging** @@ -163,16 +197,15 @@ This is only temporary and should be resolved as soon as possible, the update is #### Native Builds VS Expo Runs -##### Expo Runs ##### +##### Expo Runs There are two types of expo runs, the first is during local development, when we start our app with `npm run start` this will run the app in expo go, the second is when we release an update with `eas update`. -We currently use `expo update` when building our staging app to get a quick and easy to use link for testing *review apps* +We currently use `expo update` when building our staging app to get a quick and easy to use link for testing _review apps_ There are certain situations when this may not be possible for example we are installing a package that does not currently have an expo extension (revenue cat for in-app purchases) or we are using a native package that expo does not have access to (face id) - -When mergning into main we deploy a new staging version that can be run in expo we also build a staging version of the app as a stand-alone native build that can be ran on a device. Staging versions will point to he staging backend defined in the [eas.json](./eas.json) +When merging into main we deploy a new staging version that can be run in expo we also build a staging version of the app as a stand-alone native build that can be ran on a device. Staging versions will point to he staging backend defined in the [eas.json](./eas.json) Most internal testing should be sufficient on the expo staging build however you can also provide the link for testing with the native build. When installed this build will replace the version on your device. @@ -209,7 +242,6 @@ From the `mobile/` folder run: 1. Download the old one before deleting 1. Once you verify that builds are still working, you can delete your backup copy - #### Design system Use `tailwind.config.js` to define the styles you're going to use in the app. @@ -284,23 +316,17 @@ Run this workflow to deploy an emergency code related bugfix expo-emergency-prod-update.yml - #### Important note about custom native modules and expo eject -We can easily use our own native or non supported RN pacakges by checking if we are running an expo build or not, these will only work in expo builds not expo go. +We can easily use our own native or non supported RN pacakges by checking if we are running an expo build or not, these will only work in expo builds not expo go. When building for local testing/development we use the alternative builds in our eas.json -Recently Expo has changed the `expo eject` command for `expo pre-build` this will create the iOS and Android folders and allow you to run your project in xcode or android studio as well you will need to activate your .env file since some vars are supplied from there. +Recently Expo has changed the `expo eject` command for `expo pre-build` this will create the iOS and Android folders and allow you to run your project in xcode or android studio as well you will need to activate your .env file since some vars are supplied from there. You can accomplish this with `npm run prebuild:local` this will ensure that your `.env` file is sourced! Expo will automatically change your package.json and add/remove/change the following -- `"main"` entry will be removed +- `"main"` entry will be removed - `"start"`: `"expo start --dev-client"` will change to this <---- therefore when running prebuild ensure not to commit these changes! - - - - - diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/eas.json b/{{cookiecutter.project_slug}}/clients/mobile/react-native/eas.json index 552c464f8..cfa4b7397 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/eas.json +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/eas.json @@ -55,10 +55,10 @@ "channel": "production", "autoIncrement": true, "env": { - "BACKEND_SERVER_URL": "", + "BACKEND_SERVER_URL": "", "BUILD_ENV": "production", - "ROLLBAR_ACCESS_TOKEN": "", - "SENTRY_DSN": "" + "ROLLBAR_ACCESS_TOKEN": "", + "SENTRY_DSN": "" } } }, @@ -68,7 +68,7 @@ "appName": "", "appleId": "", "appleTeamId": "", - "ascAppId": "" } } } diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/global.d.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/global.d.ts index 7059a3128..680fc5eff 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/global.d.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/global.d.ts @@ -2,6 +2,8 @@ import { ScrollViewProps } from 'react-native' import { BounceableProps } from 'rn-bounceable' +import { CustomActionSheetProps } from '@components/sheets/custom-action-sheet' +import { SheetDefinition } from 'react-native-actions-sheet' declare global { // Extend the existing BounceableProps type export interface ExtendedBounceableProps extends BounceableProps { @@ -10,4 +12,20 @@ declare global { export interface ExtendedScrollViewProps extends ScrollViewProps { contentContainerClassName?: string } -} \ No newline at end of file + + export interface ExtendedActionSheetProps extends CustomActionSheetProps { + containerClassName?: string + indicatorClassName?: string + } +} + +declare module 'react-native-actions-sheet' { + interface Sheets { + Sample: SheetDefinition<{ + payload: { + input: string + } + }> + } +} + diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/package.json b/{{cookiecutter.project_slug}}/clients/mobile/react-native/package.json index 5ef044083..10bac54fd 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/package.json +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/package.json @@ -57,10 +57,13 @@ "expo-status-bar": "~1.12.1", "expo-updates": "~0.25.14", "expo-web-browser": "~13.0.3", + "jotai": "^2.7.1", "nativewind": "^4.0.36", + "qs": "^6.13.0", "react": "18.2.0", "react-dom": "18.2.0", "react-native": "0.74.1", + "react-native-actions-sheet": "^0.9.6", "react-native-gesture-handler": "~2.16.1", "react-native-pager-view": "6.3.0", "react-native-reanimated": "~3.10.1", @@ -83,6 +86,7 @@ "@tanstack/eslint-plugin-query": "5.35.6", "@types/i18n-js": "^3.8.3", "@types/lodash": "^4.14.185", + "@types/qs": "^6.9.15", "@types/react": "~18.2.14", "@types/react-dom": "~18.2.25", "@typescript-eslint/eslint-plugin": "^5.37.0", @@ -93,8 +97,8 @@ "eslint-config-prettier": "^8.6.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react-native": "^4.1.0", - "prettier": "^2.7.1", "tailwindcss": "^3.4.1", + "prettier": "^2.7.1", "typescript": "~5.3.3" }, "resolutions": { diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/Button.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/Button.tsx index a4a4f2ba1..942f7fb3a 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/Button.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/Button.tsx @@ -1,15 +1,15 @@ +import colors from '@utils/colors' import React, { useMemo } from 'react' import { ActivityIndicator, Platform, StyleProp, + Text, + TouchableNativeFeedback, View, ViewStyle, - TouchableNativeFeedback, } from 'react-native' import { BounceableProps } from 'rn-bounceable' -import { Text } from './text' -import colors from '@utils/colors' import { BounceableWind } from './styled' export type BButtonVariant = 'primary' | 'primary-transparent' | 'secondary' @@ -97,11 +97,11 @@ export const BButton: React.FC = ({ ) : ( {leftIcon} - {label} + {label} {rightIcon} )} ) -} \ No newline at end of file +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/contact-email-button.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/contact-email-button.tsx new file mode 100644 index 000000000..56d86e952 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/contact-email-button.tsx @@ -0,0 +1,23 @@ +import { Linking, Text } from 'react-native' +import { Bounceable } from 'rn-bounceable' +import { useConstants } from '@utils/constants' + +export const ContactEmailButton = () => { + const { supportEmail } = useConstants() + const handleSendMail = async () => { + const mailtoUrl = `mailto:${supportEmail}` + const canOpen = await Linking.canOpenURL(mailtoUrl) + if (canOpen) { + Linking.openURL(mailtoUrl) + } else { + console.error('could not open this url: ', mailtoUrl) + } + } + return ( + + + {supportEmail} + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/container.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/container.tsx new file mode 100644 index 000000000..0a6dccb00 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/container.tsx @@ -0,0 +1,22 @@ +import { FC, ReactNode } from 'react' +import { View } from 'react-native' +import { MultiPlatformSafeAreaView } from './multi-platform-safe-area-view' + +export const Container: FC<{ + children: ReactNode + containerClassName?: string + innerContainerClassName?: string + hasHorizontalPadding?: boolean +}> = ({ children, containerClassName, innerContainerClassName, hasHorizontalPadding = true }) => { + return ( + + + {children} + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/errors.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/errors.tsx index fcc590a42..448be339f 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/errors.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/errors.tsx @@ -1,10 +1,9 @@ import { IFormFieldError } from '@thinknimble/tn-forms' import { FC, Fragment, ReactNode } from 'react' -import { View } from 'react-native' -import { Text } from '@components/text' +import { Text, View } from 'react-native' export const ErrorMessage: FC<{ children: ReactNode }> = ({ children }) => { - return {children} + return {children} } /** diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/custom-action-sheet.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/custom-action-sheet.tsx new file mode 100644 index 000000000..3992a5f5b --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/custom-action-sheet.tsx @@ -0,0 +1,34 @@ +import React, { FC } from 'react' +import ActionSheet, { ActionSheetProps, ActionSheetRef } from 'react-native-actions-sheet' + +export interface CustomActionSheetProps extends ActionSheetProps { + children: React.ReactNode + actionSheetRef: React.Ref | undefined + sheetId: string +} + +export const CustomActionSheet: FC = ({ + children, + sheetId, + actionSheetRef, + snapPoints = [100], + ...rest +}) => { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/index.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/index.ts new file mode 100644 index 000000000..76e17a7c8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/index.ts @@ -0,0 +1 @@ +export * from './sheets' \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/register-sheets.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/register-sheets.ts new file mode 100644 index 000000000..bf08f2fa9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/register-sheets.ts @@ -0,0 +1,9 @@ +import { registerSheet } from 'react-native-actions-sheet' +import './styled' +import { sheets } from './sheets' + +for (const s of Object.values(sheets)) { + registerSheet(s.name, s) +} + +export {} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/sample-sheet.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/sample-sheet.tsx new file mode 100644 index 000000000..c9ec15776 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/sample-sheet.tsx @@ -0,0 +1,17 @@ +import { useRef } from 'react' +import { Text, View } from 'react-native' +import { ActionSheetRef, SheetProps } from 'react-native-actions-sheet' +import { CustomActionSheet } from './custom-action-sheet' + +export const Sample = (props: SheetProps<'Sample'>) => { + const actionSheetRef = useRef(null) + + return ( + + + Coming soon + {props.payload?.input} + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/sheets.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/sheets.ts new file mode 100644 index 000000000..b76306161 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/sheets.ts @@ -0,0 +1,12 @@ +import { Sample } from './sample-sheet' + +// add any sheets here and they will be registered in the app +export const sheets = { + test: Sample, +} as const + +export const SHEET_NAMES = Object.fromEntries( + Object.entries(sheets).map(([k, v]) => [k, v.name]), +) as Record + +export type SheetName = (typeof SHEET_NAMES)[keyof typeof SHEET_NAMES] \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/styled.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/styled.ts new file mode 100644 index 000000000..e601937fc --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/sheets/styled.ts @@ -0,0 +1,8 @@ + +import { cssInterop } from 'nativewind' +import ActionSheet from 'react-native-actions-sheet' + +cssInterop(ActionSheet, { + containerClassName: 'containerStyle', + indicatorClassName: 'indicatorStyle', +}) \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/text-form-field.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/text-form-field.tsx index 73cf001e8..5dd0f3403 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/text-form-field.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/text-form-field.tsx @@ -1,7 +1,6 @@ import Form, { IFormField } from '@thinknimble/tn-forms' import { useTnForm } from '@thinknimble/tn-forms-react' -import { TextInput, TextInputProps, View } from 'react-native' -import { Text } from '@components/text' +import { Text, TextInput, TextInputProps, View } from 'react-native' import twColors from 'tailwindcss/colors' import { ErrorsList } from '@components/errors' import { FormFieldsRecord } from '@thinknimble/tn-forms/lib/cjs/types/interfaces' @@ -14,9 +13,7 @@ export const TextFormField = , TForm extends Form() return ( - - {field.label} - + {field.label} & { variant?: FontWeightStyle; textClassName?: string } -> = ({ variant = 'regular', textClassName = '', ...props }) => { - const style = { - fontFamily: fontFamilyWeightMap[variant], - } - return -} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/ComponentsPreview.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/ComponentsPreview.tsx index 151ebfacd..66af068a8 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/ComponentsPreview.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/ComponentsPreview.tsx @@ -1,26 +1,21 @@ import React from 'react' -import {View } from 'react-native' +import { Text, View } from 'react-native' -import { Text } from '@components/text' import { MultiPlatformSafeAreaView } from '@components/multi-platform-safe-area-view' import { BButton } from '@components/Button' - -export function ComponentsPreview(){ - return ( - - - - Components Preview - - - - - - - - - - - ) -} \ No newline at end of file +export function ComponentsPreview() { + return ( + + + Components Preview + + + + + + + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/index.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/index.ts index 11d61d3bf..505af62c4 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/index.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/index.ts @@ -1,2 +1,2 @@ export { Login } from './login' -export { SignUp } from './sign-up' +export { SignUp } from './sign-up' \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/login.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/login.tsx index c56be3f4a..23c58a7f8 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/login.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/login.tsx @@ -1,11 +1,10 @@ import { MultiPlatformSafeAreaView } from '@components/multi-platform-safe-area-view' import { BounceableWind } from '@components/styled' -import { Text } from '@components/text' import { TextFormField } from '@components/text-form-field' import { LoginForm, LoginFormInputs, TLoginForm, userApi } from '@services/user' import { useAuth } from '@stores/auth' import { FormProvider, useTnForm } from '@thinknimble/tn-forms-react' -import { ScrollView, View } from 'react-native' +import { ScrollView, Text, View } from 'react-native' import { getNavio } from '../routes' const LoginInner = () => { @@ -35,11 +34,9 @@ const LoginInner = () => { return ( - - Log in - + Log in - + { disabled={!form.isValid} > - - Log In - + Log In diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/sign-up.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/sign-up.tsx index 77090d754..8dc6b4728 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/sign-up.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/auth/sign-up.tsx @@ -1,6 +1,5 @@ import { MultiPlatformSafeAreaView } from '@components/multi-platform-safe-area-view' import { BounceableWind } from '@components/styled' -import { Text } from '@components/text' import { TextFormField } from '@components/text-form-field' import { userApi } from '@services/user' import { AccountForm, TAccountForm } from '@services/user/forms' @@ -8,7 +7,7 @@ import { useAuth } from '@stores/auth' import { useMutation } from '@tanstack/react-query' import { MustMatchValidator } from '@thinknimble/tn-forms' import { FormProvider, useTnForm } from '@thinknimble/tn-forms-react' -import { ScrollView, View } from 'react-native' +import { ScrollView, Text, View } from 'react-native' import { getNavio } from '../routes' const InnerForm = () => { @@ -48,9 +47,7 @@ const InnerForm = () => { return ( - - Sign up - + Sign up @@ -60,9 +57,7 @@ const InnerForm = () => { - - Sign Up - + Sign Up diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/dashboard.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/dashboard.tsx index 1d18f5aa0..9148dfce7 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/dashboard.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/dashboard.tsx @@ -1,10 +1,59 @@ +import { BButton } from '@components/Button' import { MultiPlatformSafeAreaView } from '@components/multi-platform-safe-area-view' -import { Text } from '@components/text' +import { SHEET_NAMES } from '@components/sheets' +import { useLogout } from '@services/user' +import { navioAtom } from '@stores/navigation' +import { useAtomValue } from 'jotai' import React from 'react' +import { Text, View } from 'react-native' +import { SheetManager } from 'react-native-actions-sheet' +import Ionicons from '@expo/vector-icons/Ionicons' +import { BounceableWind } from '@components/styled' + export const DashboardScreen = () => { + const navio = useAtomValue(navioAtom) + const { mutate: logout } = useLogout() + + const onOpenSheet = () => { + SheetManager.show(SHEET_NAMES.test, { + payload: { + input: 'Hello from payload', + }, + }) + } + return ( - - Welcom to the Dash + + + + navio?.stacks.push('SettingsStack')} + > + Settings + + + + + Welcome to the Dashboard + + + + + { + logout(undefined, { + onSettled: () => { + navio?.setRoot('stacks', 'AuthStack') + }, + }) + }} + variant="primary-transparent" + /> + + + ) } diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/main.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/main.tsx index 645970945..82baa9f35 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/main.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/main.tsx @@ -1,6 +1,5 @@ -import { Dimensions, Image, StyleSheet, View } from 'react-native' +import { Dimensions, Image, StyleSheet, Text, View } from 'react-native' import logo from '@assets/tn-logo.png' -import { Text } from '@components/text' const { height } = Dimensions.get('screen') @@ -22,9 +21,7 @@ export const Main = () => { - - Welcome to my project - + Welcome to my project ) diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/routes.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/routes.ts index b823529be..bd6a8eedc 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/routes.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/routes.ts @@ -7,6 +7,7 @@ import { Main } from '@screens/main' import { Auth } from '@screens/auth/auth' import { DashboardScreen } from '@screens/dashboard' import { ComponentsPreview } from '@screens/ComponentsPreview' +import { ContactUs, EditProfile, Settings } from '@screens/settings' // Default options - forcing a mobile trigger export const screenDefaultOptions = (): NativeStackNavigationOptions => ({ @@ -29,10 +30,11 @@ export const tabDefaultOptions = (): BottomTabNavigationOptions => ({ }) // NAVIO export const navio = Navio.build({ - screens: { Auth, Login, SignUp, Main, DashboardScreen, ComponentsPreview }, + screens: { Auth, Login, SignUp, Main, DashboardScreen, ComponentsPreview, Settings, ContactUs, EditProfile }, stacks: { AuthStack: ['Auth'], MainStack: ['DashboardScreen'], + SettingsStack: ['Settings', 'ContactUs', 'EditProfile'], /** * Set me as the root to see the components preview */ @@ -49,5 +51,6 @@ export const navio = Navio.build({ export const getNavio = () => navio export const AppRoot = navio.App +export type MyNavio = typeof navio export type AppScreens = Parameters[0] export type AppStacks = Parameters[0] diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/contact-us.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/contact-us.tsx new file mode 100644 index 000000000..2bbef7cbf --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/contact-us.tsx @@ -0,0 +1,50 @@ +import { Text, View } from 'react-native' +import { BButton } from '@components/Button' +import { Container } from '@components/container' +import { getNavio } from '..' +import { ContactEmailButton } from '@components/contact-email-button' +import { Ionicons } from '@expo/vector-icons' +import colors from '@utils/colors' +import { BounceableWind } from '@components/styled' + +export const ContactUs = () => { + const navio = getNavio() + + return ( + + + { + navio.goBack() + }} + contentContainerClassName="absolute left-0 top-0" + > + + + + Contact Us + + + + + + Needing help? + + + + Reach out to us at + + + + + { + navio.goBack() + }} + /> + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/edit-profile.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/edit-profile.tsx new file mode 100644 index 000000000..0329e9ecf --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/edit-profile.tsx @@ -0,0 +1,207 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { ActivityIndicator, Alert, ScrollView, TextInput, Text, View } from 'react-native' +import { Bounceable } from 'rn-bounceable' +import { Container } from '@components/container' +import { UserShape, fullNameZod, useLogout, useUser, userApi } from '@services/user' +import { userQueries } from '@services/user/queries' +import { getNavio } from '..' +import { ErrorMessage } from '@components/errors' +import { Ionicons } from '@expo/vector-icons' +import colors from '@utils/colors' +import { BButton } from '@components/Button' +import { isAxiosError } from 'axios' +import { useAuth } from '@stores/auth' + +const Separator = () => { + return ( + + + + ) +} + +export const EditProfile = () => { + const navio = getNavio() + const onCancel = () => { + navio.goBack() + } + const { data: user } = useUser() + const { userId } = useAuth() + const [fullName, setFullName] = useState(user?.fullName ?? '') + const [errors, setErrors] = useState() + + const handleFullNameChange = (name: string) => { + setFullName(name) + } + const unsavedChanges = user && user.fullName !== fullName + + const parsedName = fullNameZod.safeParse(fullName) + const isValid = parsedName.success + const qClient = useQueryClient() + + const { mutate: save, isPending: isSaving } = useMutation({ + mutationFn: userApi.update, + onMutate: async () => { + const userSnapshot = qClient.getQueryData( + userQueries.retrieve(userId).queryKey, + )?.fullName + await qClient.cancelQueries({ queryKey: userQueries.retrieve(userId).queryKey }) + qClient.setQueryData(userQueries.retrieve(userId).queryKey, (input?: UserShape) => { + return input ? { ...input } : undefined + }) + return { userSnapshot } + }, + onSuccess: () => { + qClient.invalidateQueries({ queryKey: userQueries.all() }) + }, + onError: (e, _, context) => { + //rollback update + qClient.setQueryData(userQueries.retrieve(userId).queryKey, (input?: UserShape) => { + return input + ? { ...input, fullName: context?.userSnapshot ?? user?.fullName ?? '' } + : undefined + }) + + if (isAxiosError(e)) { + const { data } = e?.response ?? {} + if (data) { + const isArrayOfStrings = Array.isArray(data) && data.length && typeof data[0] === 'string' + const isObjectOfErrors = Object.keys(data).every((key) => Array.isArray(data[key])) + setErrors( + (isArrayOfStrings + ? data + : isObjectOfErrors + ? Object.keys(data).map((key) => data[key]) + : ['Something went wrong']) as string[], + ) + } + } + }, + }) + + const { mutate: logout, isPending: isLoggingOut } = useLogout() + + const { mutate: deleteUser, isPending: isDeleting } = useMutation({ + mutationFn: userApi.remove, + onSuccess: () => { + logout() + navio.stacks.setRoot('AuthStack') + }, + onError: () => { + Alert.alert('Error', "Couldn't delete your account. Please try again later.", [ + { + text: 'Ok', + }, + ]) + }, + }) + + const handleSave = () => { + if (!user) return + const [firstName, lastName] = fullName.split(' ') + save({ id: user.id, firstName, lastName }) + } + + const showWarningAlert = () => { + Alert.alert( + 'WARNING', + 'Deleting your account is permanent and cannot be undone. If you would like to use this app again, you will need to create a new account.', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + onPress: () => { + if (!user) return + deleteUser(user?.id) + }, + }, + ], + ) + } + + if (!user) return <> //never + + return ( + + + + + + Edit Profile + + {isSaving ? ( + + ) : ( + + Save + + )} + + + + + + + Full Name + + + + + + + + + {fullName && !isValid ? ( + + + {parsedName.error.issues.map((i) => i.message).join(', ')} + + + ) : null} + + + + Email + + + + {user.email} + + + {errors?.map((error, idx) => ( + + {error} + + ))} + + + + + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/index.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/index.tsx new file mode 100644 index 000000000..f1d6c0718 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/index.tsx @@ -0,0 +1,3 @@ +export { Settings } from './main-settings' +export { ContactUs } from './contact-us' +export { EditProfile } from './edit-profile' diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/main-settings.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/main-settings.tsx new file mode 100644 index 000000000..ee5ef36b9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/main-settings.tsx @@ -0,0 +1,206 @@ +import { BButton } from '@components/Button' +import { MaterialIcons, Ionicons, AntDesign } from '@expo/vector-icons' +import { useLogout, useUser } from '@services/user' +import * as Application from 'expo-application' +import * as Updates from 'expo-updates' +import { openBrowserAsync } from 'expo-web-browser' +import React from 'react' +import { Alert, ScrollView, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Bounceable } from 'rn-bounceable' +import { AppScreens, getNavio } from '..' +import colors from '@utils/colors' +import { Container } from '@components/container' + +import { BounceableWind } from '@components/styled' + +type SectionChild = { + title: string + icon: JSX.Element + args: + | { + screenName: AppScreens + screenProps?: Record + } + | { link: string } + } + + type Section = { + name: string + children: SectionChild[] + } + + const sections: Section[] = [ + { + name: 'About', + children: [ + { + title: 'Terms of Service', + icon: , + args: { + link: 'https://www.thinknimble.com/privacy-policy', + }, + }, + { + title: 'Privacy Policy', + icon: , + args: { + link: 'https://www.thinknimble.com/privacy-policy', + }, + }, + { + title: 'Contact us', + icon: , + args: { + screenName: 'ContactUs', + }, + }, + ], + }, + ] + + const UserCard = () => { + const { data: user } = useUser() + const navio = getNavio() + + const handlePress = () => { + navio.push('EditProfile') + } + if (!user) return <> + + return ( + + + + + + + + + {user.fullName} + + {user.email} + + + + + + + + + + ) + } + + const SectionList = () => { + const navio = getNavio() + return ( + + {sections.map((s, sIdx) => { + return ( + + + + {s.name} + + + {s.children.map((sc, scIdx) => { + return ( + { + if ('screenName' in sc.args) { + navio.push( + sc.args.screenName, + 'screenProps' in sc.args ? sc.args.screenProps : undefined, + ) + return + } else if ('link' in sc.args) { + openBrowserAsync(sc.args.link) + } + }} + key={scIdx} + > + + + + {sc.icon} + {sc.title} + + + + + + + + ) + })} + + ) + })} + + ) + } + + +export const Settings = () => { + const navio = getNavio() + const { bottom } = useSafeAreaInsets() + const { mutate: logout, isPending: isLoggingOut } = useLogout() + + const handleLogout = () => { + logout(undefined, { + onSettled: () => { + navio?.setRoot('stacks', 'AuthStack') + }, + }) + } + + const showWarningAlert = () => { + Alert.alert('Log out', 'Are you sure you want to log out?', [ + { + text: 'Cancel', + style: 'cancel', + }, + {text: 'Log out', onPress: handleLogout}, + ]) + } + + return ( + + + { + navio.goBack() + }} + contentContainerClassName="absolute left-0 top-0" + > + + + + Settings + + + + + + + + + Version released {Application.nativeApplicationVersion} ( + {Application.nativeBuildVersion}) - {Updates.channel ? Updates.channel : 'Dev'} + + + + + ) + } \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/axios-instance.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/axios-instance.ts index f79ef7f39..f8ee12c88 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/axios-instance.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/axios-instance.ts @@ -1,5 +1,6 @@ -import axios, { AxiosError } from 'axios' import { useAuth } from '@stores/auth' +import axios, { AxiosError } from 'axios' +import qs from 'qs' import Config from '../../Config' const baseUrl = @@ -8,6 +9,9 @@ const baseUrl = : `${Config?.backendServerUrl}` export const axiosInstance = axios.create({ baseURL: `${baseUrl}/api`, + paramsSerializer: (params) => { + return qs.stringify(params, { arrayFormat: 'comma' }) + }, }) console.log('axiosInstance', `${baseUrl}/api`) axiosInstance.interceptors.request.use( diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/hooks.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/hooks.ts new file mode 100644 index 000000000..dc4b2a4e6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/hooks.ts @@ -0,0 +1,39 @@ +/** + * This is a service that handles all the user state without depending on the store + * + */ + +import { useEffect } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' +import { useAuth } from '@stores/auth' +import { queryClient } from '@utils/query-client' +import { userQueries } from './queries' +import { userApi } from './api' + +export const useUser = () => { + const userId = useAuth.use.userId() + const { writeUserInStorage } = useAuth.use.actions() + const query = useQuery(userQueries.retrieve(userId)) + + useEffect(() => { + if (query.isSuccess && query.data) { + writeUserInStorage(query.data) + } + }, [query.data, query.isSuccess, writeUserInStorage]) + + return query +} + + +/** + * To use directly in components + */ +export const useLogout = () => { + return useMutation({ + mutationFn: userApi.csc.logout, + onSettled: () => { + useAuth.getState().actions.clearAuth() + queryClient.invalidateQueries({ queryKey: userQueries.all() }) + }, + }) +} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/index.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/index.ts index d863d5335..43a250bac 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/index.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/index.ts @@ -1,4 +1,5 @@ export * from './forms' export * from './api' export * from './models' -export * from './user' \ No newline at end of file +export * from './hooks' +export * from './queries' \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/models.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/models.ts index ba4c4ae5c..c59cc3eb2 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/models.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/models.ts @@ -14,6 +14,7 @@ export const userShape = { email: z.string().email(), firstName: z.string(), lastName: z.string(), + fullName: z.string(), //TODO:add back `readonly` https://github.com/thinknimble/tn-models-fp/issues/161 token: z.string().nullable(), } @@ -38,3 +39,10 @@ export const loginShape = { } export type LoginShape = GetInferredFromRaw + +export const fullNameZod = z.string().refine( + (value) => { + return value.split(' ').filter(Boolean).length >= 2 + }, + { message: 'Please provide a full name (first and last name)' }, +) diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/queries.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/queries.ts new file mode 100644 index 000000000..9adeb32c8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/queries.ts @@ -0,0 +1,19 @@ + +import { queryOptions } from '@tanstack/react-query' +import { userApi } from './api' + +/** + * @link https://tkdodo.eu/blog/the-query-options-api?ck_subscriber_id=1819338276 + * Create query factories for a more type safe solution of the queries across the app. This way whichever invalidation that has to happen in the resource can be done with `queryClient.invalidateQueries(userQueries.all())` or any other of the query factory's functions. + * In this case we do have a very simple example for the user query but depending on the resource we may want to add more query factories + */ + +export const userQueries = { + all: () => ['users'], + retrieve: (id: string) => + queryOptions({ + queryKey: [...userQueries.all(), id], + queryFn: () => userApi.retrieve(id), + enabled: Boolean(id), + }), +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/user.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/user.ts deleted file mode 100644 index 2f8946c23..000000000 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/user.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * This is a service that handles all the user state without depending on the store - * - */ - -import { useEffect } from 'react' -import { useQuery } from '@tanstack/react-query' -import { useAuth } from '@stores/auth' -import { userApi } from './api' - -export const useUser = () => { - const userId = useAuth.use.userId() - const { writeUserInStorage } = useAuth.use.actions() - const data = useQuery({ - queryKey: ['user', userId], - queryFn: async () => { - const user = await userApi.retrieve(userId) - return user - }, - enabled: Boolean(userId), - }) - - useEffect(() => { - if (data.isSuccess && data.data) { - writeUserInStorage(data.data) - } - }, [data.data, data.isSuccess, writeUserInStorage]) - - return data -} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/stores/auth.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/stores/auth.ts index 8323b2ab9..aeb609ef7 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/stores/auth.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/stores/auth.ts @@ -1,8 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' -import { UserShape as User, userApi } from '@services/user/' -import { queryClient } from '@utils/query-client' +import { UserShape as User } from '@services/user/' import { createSelectors } from '@stores/utils' type AuthState = { @@ -90,9 +89,3 @@ export const useAuth = createSelectors( ), ), ) - -export const logout = async() => { - await userApi.csc.logout() - useAuth.getState().actions.clearAuth() - queryClient.invalidateQueries({queryKey: ['user']}) -} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/stores/navigation.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/stores/navigation.ts new file mode 100644 index 000000000..21cf52c6c --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/stores/navigation.ts @@ -0,0 +1,4 @@ +import type { MyNavio } from '@screens/routes' +import { atom } from 'jotai' + +export const navioAtom = atom(null) diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/constants.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/constants.ts index f4034be5b..15c002fa8 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/constants.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/constants.ts @@ -9,5 +9,6 @@ export const useConstants = () => { github: 'https://github.com/kanzitelli/expo-starter', website: 'https://github.com/kanzitelli/expo-starter', }, + supportEmail: 'hello@thinknimble.com', }; }; diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/fonts.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/fonts.ts index b45f33378..784881052 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/fonts.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/fonts.ts @@ -1,4 +1,4 @@ -//TODO: change this to your family, we're using monserrat as base. You could also add more family weights! +//TODO: change this to your family, we're using montserrat as base. You could also add more family weights! const baseFamily = 'Montserrat' as const const fontFormat = 'ttf' @@ -14,30 +14,3 @@ export const customFonts = { [`${baseFamily}-MediumItalic` as const]: require(`../../assets/fonts/${baseFamily}-MediumItalic.${fontFormat}`), [`${baseFamily}-Regular` as const]: require(`../../assets/fonts/${baseFamily}-Regular.${fontFormat}`), } - -type FontFamily = keyof typeof customFonts - -export type FontWeightStyle = - | 'light' - | 'italic-light' - | 'regular' - | 'italic' - | 'medium' - | 'italic-medium' - | 'black' - | 'italic-black' - | 'bold' - | 'italic-bold' - -export const fontFamilyWeightMap: Record = { - light: `${baseFamily}-Light`, - 'italic-light': `${baseFamily}-LightItalic`, - regular: `${baseFamily}-Regular`, - italic: `${baseFamily}-Italic`, - medium: `${baseFamily}-Medium`, - 'italic-medium': `${baseFamily}-MediumItalic`, - black: `${baseFamily}-Black`, - 'italic-black': `${baseFamily}-BlackItalic`, - bold: `${baseFamily}-Bold`, - 'italic-bold': `${baseFamily}-BoldItalic`, -} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/tailwind.config.js b/{{cookiecutter.project_slug}}/clients/mobile/react-native/tailwind.config.js index 2955ac39e..4062765aa 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/tailwind.config.js +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/tailwind.config.js @@ -7,6 +7,18 @@ module.exports = { theme: { extend: { colors, + fontFamily: { + 'primary-black': `Montserrat-Black`, + 'primary-black-italic': `Montserrat-BlackItalic`, + 'primary-bold': `Montserrat-Bold`, + 'primary-bold-italic': `Montserrat-BoldItalic`, + 'primary-italic': `Montserrat-Italic`, + 'primary-light': `Montserrat-Light`, + 'primary-light-italic': `Montserrat-LightItalic`, + 'primary-medium': `Montserrat-Medium`, + 'primary-medium-italic': `Montserrat-MediumItalic`, + 'primary-regular': `Montserrat-Regular`, + }, }, }, plugins: [], diff --git a/{{cookiecutter.project_slug}}/clients/web/react/.env.local.example b/{{cookiecutter.project_slug}}/clients/web/react/.env.local.example index ae89d24b5..7b4e6a951 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/.env.local.example +++ b/{{cookiecutter.project_slug}}/clients/web/react/.env.local.example @@ -1,5 +1,5 @@ # When running the backend manually locally with `python manage.py runserver` -# VITE_DEV_BACKEND_URL="http://localhost:8080" +# VITE_DEV_BACKEND_URL="http://localhost:8000" # When running locally with Docker # Disable VITE_DEV_BACKEND_URL here. Docker will find the backend on it's own diff --git a/{{cookiecutter.project_slug}}/clients/web/react/.gitignore b/{{cookiecutter.project_slug}}/clients/web/react/.gitignore index 4d29575de..2b8d731ad 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/.gitignore +++ b/{{cookiecutter.project_slug}}/clients/web/react/.gitignore @@ -21,3 +21,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Playwright files +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/web/react/README.md b/{{cookiecutter.project_slug}}/clients/web/react/README.md index 1a82fd924..ec9d7fd75 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/README.md +++ b/{{cookiecutter.project_slug}}/clients/web/react/README.md @@ -17,7 +17,7 @@ This app includes basic configurations for developers to have a starting point o - TN Forms - Vitest - React testing library -- Cypress +- Playwright ## Getting started @@ -49,6 +49,7 @@ npm i First, create .env.local at the top-level of the client directory, and copy the contents of .env.local.example into it. Update the value of VITE_DEV_BACKEND_URL to point to your desired backend. Then run the project with: + ``` npm run serve ``` @@ -73,10 +74,16 @@ If you want to watch a single test you can specify its path as an argument to: npm run test:single path/to/test/file ``` -### Run e2e tests with Cypress +### Run e2e tests with Playwright ``` -npm run cypress +npm run test:e2e ``` -Will open cypress wizard. Make sure you run your app locally with `npm run start` and them choose the test you want to run from the wizard. +Tests are run in headless mode meaning no browser will open up when running the tests. Results of the tests and test logs will be shown in the terminal. + +To open last HTML report run: + +``` +npx playwright show-report +``` diff --git a/{{cookiecutter.project_slug}}/clients/web/react/cypress.config.ts b/{{cookiecutter.project_slug}}/clients/web/react/cypress.config.ts deleted file mode 100644 index 9d63d2ecb..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/react/cypress.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'cypress' -import pluginsFile from './tests/e2e/plugins' - -export default defineConfig({ - e2e: { - baseUrl: 'http://localhost:8080', - setupNodeEvents: pluginsFile, - supportFile: 'tests/e2e/support/e2e.js', - specPattern: 'tests/e2e/**/*.cy.{js,jsx,ts,tsx}', - }, -}) diff --git a/{{cookiecutter.project_slug}}/clients/web/react/cypress.example.env.json b/{{cookiecutter.project_slug}}/clients/web/react/cypress.example.env.json deleted file mode 100644 index 939ade11d..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/react/cypress.example.env.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "TEST_USER_PASS": "!!!POSTGRES_PASSWORD!!!", - "TEST_USER_EMAIL": "cypress@example.com" -} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/web/react/package.json b/{{cookiecutter.project_slug}}/clients/web/react/package.json index 1e9b4c404..532b4fad6 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/package.json +++ b/{{cookiecutter.project_slug}}/clients/web/react/package.json @@ -5,8 +5,8 @@ "scripts": { "serve": "vite --host 0.0.0.0", "build": "vite build --base=/static/", - "cypress": "source ../.env && cypress open", "test": "vitest run", + "test:e2e": "npx playwright test --reporter=html", "test:dev": "vitest", "test:watch": "vitest run", "test:single": "vitest $0", @@ -21,6 +21,7 @@ "@thinknimble/tn-forms-react": "^1.0.3", "@thinknimble/tn-models": "^3.0.0", "axios": "^1.5.0", + "qs": "^6.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.10.1", @@ -29,10 +30,13 @@ "zustand": "^4.4.0" }, "devDependencies": { + "@playwright/test": "^1.46.0", "@tanstack/eslint-plugin-query": "5.35.6", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.4.3", + "@types/node": "^22.1.0", + "@types/qs": "^6.9.15", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "@types/react-router-dom": "^5.3.3", @@ -40,7 +44,7 @@ "@typescript-eslint/parser": "^6.19.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.15", - "cypress": "^13.5.1", + "dotenv": "^16.4.5", "eslint": "^8.48.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", diff --git a/{{cookiecutter.project_slug}}/clients/web/react/playwright.config.ts b/{{cookiecutter.project_slug}}/clients/web/react/playwright.config.ts new file mode 100644 index 000000000..39186a038 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/playwright.config.ts @@ -0,0 +1,55 @@ +import { defineConfig, devices } from '@playwright/test' +import dotenv from 'dotenv' +import path from 'path' + +dotenv.config({ path: path.resolve(__dirname, '.env') }) + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e/specs', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:8080', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/bars.svg b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/bars.svg new file mode 100644 index 000000000..ea05b8c4d --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/bars.svg @@ -0,0 +1,3 @@ + + + diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/glyph.svg b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/glyph.svg new file mode 100644 index 000000000..5cd6ccfaf --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/glyph.svg @@ -0,0 +1,3 @@ + + + diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/icon.png b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/icon.png new file mode 100644 index 000000000..60af5f7ff Binary files /dev/null and b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/icon.png differ diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/loading.svg b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/loading.svg new file mode 100644 index 000000000..354ddc1f9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/loading.svg @@ -0,0 +1,4 @@ + + + + diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/logo.svg b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/logo.svg index 620fe2632..17c08afe2 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/logo.svg +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/logo.svg @@ -1,10 +1,23 @@ - - - + + + + + + + + + + + + + + + + + - - - - + + + diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/profile-circle.svg b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/profile-circle.svg new file mode 100644 index 000000000..4dc6235e7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/profile-circle.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/x-mark.svg b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/x-mark.svg new file mode 100644 index 000000000..457d8e626 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/assets/images/x-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/components/auth-layout.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/components/auth-layout.tsx index 350e2ddf2..ed6163c8e 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/components/auth-layout.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/components/auth-layout.tsx @@ -1,20 +1,21 @@ import { FC, ReactNode } from 'react' -import { Outlet } from 'react-router-dom' +import { Logo } from 'src/components/logo' -export const AuthLayout: FC<{ title: string; description: string; children: ReactNode }> = ({ +export const AuthLayout: FC<{ title: string; description?: string; children: ReactNode }> = ({ children, description, title, }) => { return ( -
-
-
-
{title}
-

{description}

- - {children} - - +
+
+ +
+ {title} +
+ {description &&

{description}

} +
+ {children} +
) } diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/components/button.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/components/button.tsx index 7376c3a3f..c20f4268e 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/components/button.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/components/button.tsx @@ -2,11 +2,13 @@ import { ButtonHTMLAttributes, FC, ReactNode } from 'react' import { Link, LinkProps } from 'react-router-dom' import { Spinner } from './spinner' -type ButtonVariant = 'primary' | 'ghost' | 'discreet' +type ButtonVariant = 'primary' | 'accent' | 'disabled' const buttonVariantMap: Record = { - primary: 'text-white bg-primary px-4 py-2 active:bg-slate-400 ', - discreet: 'text-primary hover:shadow-none', - ghost: 'border border-primary text-primary py-2 px-4 active:bg-primary active:text-white', + primary: + 'flex w-full cursor-pointer items-center justify-center rounded-md border px-3 py-2 text-sm font-semibold shadow-sm border-primary text-white hover:bg-primaryLight bg-primary', + accent: '', + disabled: + 'flex w-full cursor-pointer items-center justify-center rounded-md border px-3 py-2 text-sm font-semibold shadow-sm cursor-not-allowed border-gray-200 bg-gray-200', } type CommonButtonProps = { @@ -40,7 +42,7 @@ export const Button: FC< className={` rounded-lg transition-transform hover:scale-[1.05] hover:shadow-lg disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:transform-none ${buttonVariantMap[variant]} ${extendClassName}`} disabled={props.disabled || isLoading} > - {props.isLoading ? : children} + {props.isLoading ? : children} ) } diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/components/errors.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/components/errors.tsx index 47810e787..6b03fc826 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/components/errors.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/components/errors.tsx @@ -2,7 +2,7 @@ import { IFormFieldError } from '@thinknimble/tn-forms' import { FC, Fragment, ReactNode } from 'react' export const ErrorMessage: FC<{ children: ReactNode }> = ({ children }) => { - return

{children}

+ return

{children}

} /** diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/components/input.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/components/input.tsx index 84873375b..ebb3a034c 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/components/input.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/components/input.tsx @@ -1,24 +1,31 @@ import { FC, InputHTMLAttributes, ReactNode } from 'react' export const Input: FC< - InputHTMLAttributes & { extendClassName?: string; icon?: ReactNode } -> = ({ className, extendClassName, icon, ...props }) => { + InputHTMLAttributes & { + extendClassName?: string + icon?: ReactNode + label?: string + } +> = ({ className, extendClassName, icon, label, ...props }) => { return ( -
- - {icon ? ( -
{icon}
- ) : null} +
+ {label && {label}} +
+ + {icon ? ( +
{icon}
+ ) : null} +
) } diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/components/logo.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/components/logo.tsx new file mode 100644 index 000000000..b9fc58ef1 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/components/logo.tsx @@ -0,0 +1,5 @@ +import LogoImage from '../assets/images/icon.png' + +export const Logo = () => { + return ThinkNimble +} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/components/nav-bar.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/components/nav-bar.tsx new file mode 100644 index 000000000..17a93e359 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/components/nav-bar.tsx @@ -0,0 +1,212 @@ +import { NavLink, Link, useNavigate } from 'react-router-dom' +import { useAuth } from 'src/stores/auth' +import { useState } from 'react' +import { useLogout, useUser } from 'src/services/user' +import BarsIcon from '../assets/images/bars.svg' +import XMark from '../assets/images/x-mark.svg' +import Logo from '../assets/images/logo.svg' +import ProfileCircle from '../assets/images/profile-circle.svg' +import { User } from 'src/services/user/models' + +const UserInfo = ({ user }: { user: User | undefined }) => { + return ( + <> +
+ {user?.firstName} {user?.lastName} +
+
{user?.email}
+ + ) +} + +export const NavBar = () => { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [profileMenuOpen, setProfileMenuOpen] = useState(false) + + const token = useAuth.use.token() + const { data: user } = useUser() + const isAuth = Boolean(token) + const { mutate: logout } = useLogout() + + const navigate = useNavigate() + const logOutUser = () => { + toggleMobileMenu() + logout(undefined, { + onSettled: () => { + navigate('/log-in') + }, + }) + } + + const toggleMobileMenu = () => setMobileMenuOpen(!mobileMenuOpen) + + return ( + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/components/password-input.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/components/password-input.tsx index a14e8e7a4..46b8285ba 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/components/password-input.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/components/password-input.tsx @@ -3,7 +3,11 @@ import { Input } from './input' import { FaEye, FaEyeSlash } from 'react-icons/fa' export const PasswordInput: FC< - InputHTMLAttributes & { extendClassName?: string; iconTabIndex?: number } + InputHTMLAttributes & { + extendClassName?: string + iconTabIndex?: number + label?: string + } > = ({ extendClassName, iconTabIndex, ...props }) => { const [showPassword, setShowPassword] = useState(false) const onTogglePassword = () => { diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/components/spinner.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/components/spinner.tsx index 873a23ced..f63fed61b 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/components/spinner.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/components/spinner.tsx @@ -2,7 +2,7 @@ import { CSSProperties, FC } from 'react' import { colors } from 'tailwind.colors' export type SizeVariant = 'xs' | 'sm' | 'md' | 'lg' | 'xl' -const BASE_SIZE = 20 +const BASE_SIZE = 12 const BASE_BORDER = 2 const PRIMARY_COLOR = colors.primary[500] const mapWidthHeightBySize: Record< diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/dashboard.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/dashboard.tsx new file mode 100644 index 000000000..0e60376f2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/dashboard.tsx @@ -0,0 +1,12 @@ +export const Dashboard = () => { + return ( +
+
+
+

Dashboard

+
+
+
Content goes here...
+
+ ) +} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/home.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/home.tsx index 91143af2d..951259d10 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/home.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/home.tsx @@ -1,18 +1,31 @@ -import React from 'react' -import { useNavigate } from 'react-router-dom' -import { logout } from 'src/stores/auth' +import { Link } from 'react-router-dom' +import { useAuth } from 'src/stores/auth' export const Home = () => { - const navigate = useNavigate() - const logOutUser = () => { - logout() - navigate('/log-in') - } + const token = useAuth.use.token() + const isAuth = Boolean(token) return ( <> -

Dashboard

- +
+

+ Welcome to {{ cookiecutter.project_name }}! +

+

+ Here's some information about {{ cookiecutter.project_name }}. Please update and + expand on this text. This text is the first thing that users will see on the home page. +

+ {isAuth && ( +
+ + Get Started + +
+ )} +
) } diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/layout.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/layout.tsx index 999caa27f..db540f8e1 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/layout.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/layout.tsx @@ -1,8 +1,10 @@ import { Outlet } from 'react-router-dom' +import { NavBar } from 'src/components/nav-bar' export const Layout = () => { return ( -
+
+
) diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/log-in.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/log-in.tsx index 46f96f1a6..3e8d4f53e 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/log-in.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/log-in.tsx @@ -3,33 +3,33 @@ import { FormProvider, useTnForm } from '@thinknimble/tn-forms-react' import { useState } from 'react' import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom' import { Button } from 'src/components/button' -import { ErrorsList } from 'src/components/errors' +import { ErrorMessage, ErrorsList } from 'src/components/errors' import { Input } from 'src/components/input' -import { LoginForm, LoginFormInputs, TLoginForm, userApi } from 'src/services/user' +import { LoginForm, TLoginForm, LoginFormInputs, userApi } from 'src/services/user' -import { useAuth } from 'src/stores/auth' import { useFollowupRoute } from 'src/utils/auth' +import { useAuth } from 'src/stores/auth' +import { PasswordInput } from 'src/components/password-input' +import { getErrorMessages } from 'src/utils/errors' +import { AuthLayout } from 'src/components/auth-layout' function LogInInner() { const params = useLocation() - const autoError = params.state?.autoError - const [error, setError] = useState(autoError ? true : false) + const [errorMessage, setErrorMessage] = useState() const { changeToken, changeUserId } = useAuth.use.actions() const { createFormFieldChangeHandler, form } = useTnForm() const navigate = useNavigate() - const { mutate: logIn } = useMutation({ + const { mutate: logIn, isPending } = useMutation({ mutationFn: userApi.csc.login, onSuccess: (data) => { - if (!data.token) throw new Error('Missing token from response') changeToken(data.token) changeUserId(data.id) - navigate('/home') + navigate('/dashboard') }, onError(e: any) { - if (e?.message === 'Please enter valid credentials') { - setError(true) - } + const errors = getErrorMessages(e) + setErrorMessage(errors) }, }) @@ -49,11 +49,9 @@ function LogInInner() { } return ( -
-
Login
-
-

Enter your login credentials below

-
+
+ { e.preventDefault() }} @@ -61,47 +59,59 @@ function LogInInner() { >
createFormFieldChangeHandler(form.email)(e.target.value)} value={form.email.value ?? ''} - data-cy="email" + data-testid="email" id="id" + label="Email address" />
- { - createFormFieldChangeHandler(form.password)(e.target.value) - }} - value={form.password.value ?? ''} - data-cy="password" - id="password" - /> - +
+
+ +
+ +

Forgot password?

+ +
+
+ { + createFormFieldChangeHandler(form.password)(e.target.value) + }} + value={form.password.value ?? ''} + data-testid="password" + id="password" + /> + +
-
- - Forgot password? - -
-
-
-

Don't have an account?

- - Register here + +
+

Don't have an account?

+ + Sign up.
-
+ ) } diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/page-not-found.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/page-not-found.tsx new file mode 100644 index 000000000..7f04b7a1a --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/page-not-found.tsx @@ -0,0 +1,23 @@ +import { Link } from 'react-router-dom' + +export const PageNotFound = () => { + return ( +
+
+

404

+

Page not found

+

+ Sorry, we couldn't find the page you're looking for. +

+
+ + Go back home + +
+
+
+ ) +} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/request-password-reset.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/request-password-reset.tsx new file mode 100644 index 000000000..31ef49e1c --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/request-password-reset.tsx @@ -0,0 +1,108 @@ +import { useMutation } from '@tanstack/react-query' +import { FormProvider, useTnForm } from '@thinknimble/tn-forms-react' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { AuthLayout } from 'src/components/auth-layout' +import { Button } from 'src/components/button' +import { ErrorMessage, ErrorsList } from 'src/components/errors' +import { Input } from 'src/components/input' +import { + EmailForgotPasswordForm, + EmailForgotPasswordInput, + TEmailForgotPasswordForm, + userApi, +} from 'src/services/user' +import { getErrorMessages } from 'src/utils/errors' + +export const RequestPasswordResetInner = () => { + const [errorMessage, setErrorMessage] = useState() + const [resetLinkSent, setResetLinkSent] = useState(false) + const { createFormFieldChangeHandler, form } = useTnForm() + const navigate = useNavigate() + + const { mutate: requestReset } = useMutation({ + mutationFn: userApi.csc.requestPasswordReset, + onSuccess: (data) => { + setErrorMessage(undefined) + setResetLinkSent(true) + }, + onError(e: any) { + const errors = getErrorMessages(e) + setErrorMessage(errors) + }, + }) + + const handleRequest = () => { + const input = { + email: form.email.value ?? '', + } + requestReset(input) + } + + return ( + +
+ {resetLinkSent ? ( + <> +

+ Your request has been submitted. If there is an account associated with the email + provided, you should receive an email momentarily with instructions to reset your + password. +

+

+ If you do not see the email in your main folder soon, please make sure to check your + spam folder. +

+
+ +
+ + ) : ( + <> +
{ + e.preventDefault() + }} + className="flex flex-col gap-2" + > + createFormFieldChangeHandler(form.email)(e.target.value)} + value={form.email.value ?? ''} + data-cy="email" + id="id" + label="Email address" + /> + +
+ {errorMessage} +
+ + + + )} +
+
+ ) +} + +export const RequestPasswordReset = () => { + return ( + formClass={EmailForgotPasswordForm}> + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/reset-password.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/reset-password.tsx index 0d28d45b5..1f27a30d3 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/reset-password.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/reset-password.tsx @@ -2,7 +2,7 @@ import { useMutation } from '@tanstack/react-query' import { MustMatchValidator } from '@thinknimble/tn-forms' import { FormProvider, useTnForm } from '@thinknimble/tn-forms-react' import { isAxiosError } from 'axios' -import { FormEvent, useState } from 'react' +import { FormEvent, useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { AuthLayout } from 'src/components/auth-layout' import { Button } from 'src/components/button' @@ -11,8 +11,9 @@ import { PasswordInput } from 'src/components/password-input' import { ResetPasswordForm, TResetPasswordForm, userApi } from 'src/services/user' export const ResetPasswordInner = () => { - const { form, createFormFieldChangeHandler } = useTnForm() + const { form, createFormFieldChangeHandler, overrideForm } = useTnForm() const { userId, token } = useParams() + console.log(userId, token) const [error, setError] = useState('') const [success, setSuccess] = useState(false) @@ -30,13 +31,21 @@ export const ResetPasswordInner = () => { }, }) + useEffect(() => { + if (token && userId) { + overrideForm(ResetPasswordForm.create({ token: token, uid: userId }) as TResetPasswordForm) + } + }, [overrideForm, token, userId]) + const onSubmit = (e: FormEvent) => { e.preventDefault() - if (form.isValid && userId && token && form.password.value) { + console.log(form.value) + + if (form.isValid) { confirmResetPassword({ - userId, - token, - password: form.password.value, + userId: form.value.uid!, + token: form.value.token!, + password: form.value.password!, }) } } @@ -47,7 +56,7 @@ export const ResetPasswordInner = () => { title="Successfully reset password" description="You can now log in with your new password" > - + Go to login @@ -55,36 +64,38 @@ export const ResetPasswordInner = () => { } return ( -
-
- createFormFieldChangeHandler(form.password)(e.target.value)} - extendClassName="w-full" - placeholder={form.password.placeholder} - tabIndex={1} - iconTabIndex={4} - /> - -
-
- { - createFormFieldChangeHandler(form.confirmPassword)(e.target.value) - }} - extendClassName="w-full" - placeholder={form.confirmPassword.placeholder} - tabIndex={2} - iconTabIndex={5} - /> - -
- -
- {error ? {error} : null} +
+
+
+ createFormFieldChangeHandler(form.password)(e.target.value)} + extendClassName="w-full" + placeholder={form.password.placeholder} + tabIndex={1} + iconTabIndex={4} + /> + +
+
+ { + createFormFieldChangeHandler(form.confirmPassword)(e.target.value) + }} + extendClassName="w-full" + placeholder={form.confirmPassword.placeholder} + tabIndex={2} + iconTabIndex={5} + /> + +
+ +
+ {error ? {error} : null} +
) } @@ -102,4 +113,4 @@ export const ResetPassword = () => { ) -} +} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/sign-up.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/sign-up.tsx index c12a993ba..9fc6cb76c 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/pages/sign-up.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/pages/sign-up.tsx @@ -6,11 +6,13 @@ import { Link, useNavigate } from 'react-router-dom' import { Button } from 'src/components/button' import { ErrorMessage, ErrorsList } from 'src/components/errors' import { Input } from 'src/components/input' +import { AccountForm, TAccountForm, AccountFormInputs } from 'src/services/user/forms' import { userApi } from 'src/services/user' -import { AccountForm, AccountFormInputs, TAccountForm } from 'src/services/user/forms' import { isAxiosError } from 'axios' import { GENERIC_REQUEST_ERROR } from 'src/utils/constants' import { useAuth } from 'src/stores/auth' +import { PasswordInput } from 'src/components/password-input' +import { AuthLayout } from 'src/components/auth-layout' function SignUpInner() { const [errors, setErrors] = useState([]) @@ -57,80 +59,92 @@ function SignUpInner() { } return ( -
-
WELCOME
-

Enter your details below to create an account

-
-
-
- { - createFormFieldChangeHandler(form.firstName)(e.target.value) - }} - /> - + +
+ +
+
+ { + createFormFieldChangeHandler(form.firstName)(e.target.value) + }} + label="First Name" + data-testid="first-name" + /> + +
+
+ { + createFormFieldChangeHandler(form.lastName)(e.target.value) + }} + label="Last Name" + data-testid="last-name" + /> + + +
{ - createFormFieldChangeHandler(form.lastName)(e.target.value) + createFormFieldChangeHandler(form.email)(e.target.value) }} + label="Email" + data-testid="email" /> - - +
-
-
- { - createFormFieldChangeHandler(form.email)(e.target.value) - }} - /> - -
-
- { createFormFieldChangeHandler(form.password)(e.target.value) }} + label="Password" + data-testid="password" /> -
-
- { createFormFieldChangeHandler(form.confirmPassword)(e.target.value) }} + label="Confirm Password" + data-testid="confirm-password" /> -
- - - {errors.length + + {errors.length ? errors.map((e, idx) => {e}) : null} -
-

Already have an account?

- - Log in here + +
+
+

Already have an account?

+ + + Log in.
-
+ ) } const confirmPasswordValidator = { confirmPassword: new MustMatchValidator({ - message: 'passwordsMustMatch', + message: 'Passwords must match', matcher: 'password', }), } diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/services/axios-instance.ts b/{{cookiecutter.project_slug}}/clients/web/react/src/services/axios-instance.ts index 6f3e5d9bb..0aedc5b6c 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/services/axios-instance.ts +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/services/axios-instance.ts @@ -1,4 +1,5 @@ import axios, { AxiosError } from 'axios' +import qs from 'qs' import { useAuth } from 'src/stores/auth' import { getCookie } from 'src/utils/get-cookie' @@ -9,6 +10,9 @@ export const axiosInstance = axios.create({ headers: { 'Content-Type': 'application/json', }, + paramsSerializer: (params) => { + return qs.stringify(params, { arrayFormat: 'comma' }) + }, }) axiosInstance.interceptors.request.use( diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/services/user/forms.ts b/{{cookiecutter.project_slug}}/clients/web/react/src/services/user/forms.ts index 56585d5ca..eeddae331 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/services/user/forms.ts +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/services/user/forms.ts @@ -89,22 +89,22 @@ export class EmailForgotPasswordForm extends Form { export type TEmailForgotPasswordForm = EmailForgotPasswordForm & EmailForgotPasswordInput export type ResetPasswordInput = { - email: IFormField - code: IFormField + uid: IFormField + token: IFormField password: IFormField confirmPassword: IFormField } export class ResetPasswordForm extends Form { - static email = new FormField({ - label: 'Email', - placeholder: 'Email', - type: 'emailAddress', - validators: [new EmailValidator({ message: 'Please enter a valid email' })], + static uid = new FormField({ + label: 'UID', + placeholder: 'uid', + type: 'text', + validators: [new RequiredValidator({ message: 'Please enter a valid uid' })], }) - static code = new FormField({ - placeholder: 'Verification Code', - type: 'number', + static token = new FormField({ + placeholder: 'Verification Token', + type: 'text', validators: [ new MinLengthValidator({ message: 'Please enter a valid 5 digit code', minLength: 5 }), ], @@ -167,4 +167,4 @@ export class ForgotPasswordForm extends Form { }) } -export type TForgotPasswordForm = ForgotPasswordForm & ForgotPasswordInput +export type TForgotPasswordForm = ForgotPasswordForm & ForgotPasswordInput \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/services/user/hooks.ts b/{{cookiecutter.project_slug}}/clients/web/react/src/services/user/hooks.ts index 29ad7a0e1..8bc62ef10 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/services/user/hooks.ts +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/services/user/hooks.ts @@ -1,9 +1,11 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation } from '@tanstack/react-query' import { useAuth } from 'src/stores/auth' import { useEffect } from 'react' import { HttpStatusCode, isAxiosError } from 'axios' import { useNavigate } from 'react-router-dom' import { userQueries } from './queries' +import { queryClient } from 'src/utils/query-client' +import { userApi } from './api' export const useUser = () => { const { clearAuth } = useAuth.use.actions() @@ -32,3 +34,17 @@ export const useUser = () => { return query } + +/** + * To use directly in components + */ +export const useLogout = () => { + return useMutation({ + mutationFn: userApi.csc.logout, + onSettled: () => { + useAuth.getState().actions.clearAuth() + queryClient.invalidateQueries({ queryKey: userQueries.all() }) + localStorage.clear() + }, + }) +} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/stores/auth.ts b/{{cookiecutter.project_slug}}/clients/web/react/src/stores/auth.ts index b618e410d..41421587b 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/stores/auth.ts +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/stores/auth.ts @@ -1,7 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -import { User, userApi, userQueries } from '../services/user' -import { queryClient } from '../utils/query-client' +import { User } from '../services/user' import { createSelectors } from './utils' type AuthState = { @@ -85,13 +84,3 @@ export const useAuth = createSelectors( ), ), ) - -export const logout = async () => { - try { - await userApi.csc.logout() - } catch (e) { - console.error - } - useAuth.getState().actions.clearAuth() - queryClient.invalidateQueries({ queryKey: userQueries.all() }) -} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/utils/errors.ts b/{{cookiecutter.project_slug}}/clients/web/react/src/utils/errors.ts new file mode 100644 index 000000000..b51f08839 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/utils/errors.ts @@ -0,0 +1,26 @@ +import { isAxiosError } from 'axios' + +type ErrorData = string[] | { [key: string]: string[] } + +function extractErrorMessages(data: ErrorData | undefined): string[] { + if (Array.isArray(data) && data.length && typeof data[0] === 'string') { + return data + } else if ( + typeof data === 'object' && + Object.keys(data).every((key) => Array.isArray((data as { [key: string]: string[] })[key])) + ) { + // Use type assertion within every callback to access property with key + return Object.values(data).flat() + } else { + return ['Something went wrong'] + } +} + +export function getErrorMessages(e: Error, defaultMessage = 'Something went wrong'): string[] { + if (isAxiosError(e)) { + const { data } = e.response ?? {} + return extractErrorMessages(data) + } else { + return [defaultMessage] + } +} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/src/utils/routes.tsx b/{{cookiecutter.project_slug}}/clients/web/react/src/utils/routes.tsx index 5fff19ba2..855c23dec 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/src/utils/routes.tsx +++ b/{{cookiecutter.project_slug}}/clients/web/react/src/utils/routes.tsx @@ -1,30 +1,28 @@ -import { Navigate, Route, Routes } from 'react-router-dom' -import { Home, LogIn, SignUp } from 'src/pages' -import { ForgotPassword } from 'src/pages/forgot-password' +import { Route, Routes, Navigate } from 'react-router-dom' +import { Home, Layout, LogIn, SignUp } from 'src/pages' +import { Dashboard } from 'src/pages/dashboard' +import { PageNotFound } from 'src/pages/page-not-found' +import { RequestPasswordReset } from 'src/pages/request-password-reset' import { ResetPassword } from 'src/pages/reset-password' import { useAuth } from 'src/stores/auth' const PrivateRoutes = () => { return ( - - } /> + <> + } /> Hello from private
} /> - } /> - + ) } const AuthRoutes = () => { return ( - - - } /> - } /> - } /> - } /> - } /> - - + <> + } /> + } /> + } /> + } /> + ) } @@ -32,6 +30,14 @@ export const AppRoutes = () => { const token = useAuth.use.token() const isAuth = Boolean(token) - if (!isAuth) return - return + return ( + + }> + } /> + } /> + {isAuth ? PrivateRoutes() : AuthRoutes()} + } /> + + + ) } diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tailwind.colors.js b/{{cookiecutter.project_slug}}/clients/web/react/tailwind.colors.js index 20d611b8b..bb8156f5f 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/tailwind.colors.js +++ b/{{cookiecutter.project_slug}}/clients/web/react/tailwind.colors.js @@ -1,17 +1,9 @@ export const colors = { - primary: { - DEFAULT: '#f0574f', - 50: '#fef3f2', - 100: '#fee3e2', - 200: '#fecdca', - 300: '#fca9a5', - 400: '#f87871', - 500: '#f0574f', - 600: '#dc2f26', - 700: '#b9241c', - 800: '#99211b', - 900: '#7f221d', - 950: '#450d0a', - }, + primary: '#042642', + primaryLight: '#183A56', + accent: '#d93a00', + success: '#4faf64', + warning: '#f4b942', + error: '#d72638', dark: '#2a2d2e', } diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tailwind.config.js b/{{cookiecutter.project_slug}}/clients/web/react/tailwind.config.js index 5763fbf84..72cbfc127 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/tailwind.config.js +++ b/{{cookiecutter.project_slug}}/clients/web/react/tailwind.config.js @@ -6,6 +6,18 @@ module.exports = { theme: { extend: { colors, + fontFamily: { + avenir: ['Avenir', 'Helvetica', 'Arial', 'sans-serif'], + }, + }, + container: { + padding: { + DEFAULT: '1rem', + sm: '2rem', + lg: '4rem', + xl: '5rem', + '2xl': '6rem', + }, }, }, plugins: [], diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/.eslintrc.js b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/.eslintrc.js deleted file mode 100644 index a3e436bc3..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/.eslintrc.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - plugins: ['cypress'], - env: { - mocha: true, - 'cypress/globals': true, - }, - rules: { - strict: 'off', - }, -} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/plugins/index.js b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/plugins/index.js deleted file mode 100644 index b150c40be..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/plugins/index.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable arrow-body-style */ -// https://docs.cypress.io/guides/guides/plugins-guide.html - -// if you need a custom webpack configuration you can uncomment the following import -// and then use the `file:preprocessor` event -// as explained in the cypress docs -// https://docs.cypress.io/api/plugins/preprocessors-api.html#Examples - -// /* eslint-disable import/no-extraneous-dependencies, global-require */ -// const webpack = require('@cypress/webpack-preprocessor') - -export default (on, config) => { - // on('file:preprocessor', webpack({ - // webpackOptions: require('@vue/cli-service/webpack.config'), - // watchOptions: {} - // })) - - return Object.assign({}, config, { - fixturesFolder: 'tests/e2e/fixtures', - screenshotsFolder: 'tests/e2e/screenshots', - videosFolder: 'tests/e2e/videos', - }) -} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/home.spec.ts b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/home.spec.ts new file mode 100644 index 000000000..9b551d4f7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/home.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test' + +test.beforeEach(async ({ page }) => { + await page.goto('/') +}) + +test('Has Welcome text', async ({ page }) => { + await expect(page.getByText('Welcome')).toBeVisible() +}) + +test('Login and signup buttons are visible', async ({ page }) => { + await expect(page.getByText('Login')).toBeVisible() + await expect(page.getByText('Signup')).toBeVisible() +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/login.spec.ts b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/login.spec.ts new file mode 100644 index 000000000..e118f8638 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/login.spec.ts @@ -0,0 +1,14 @@ +// @ts-check +import { test, expect } from '@playwright/test' +import dotenv from 'dotenv' + +test('Login workflow', async ({ page }) => { + expect(process.env.PLAYWRIGHT_TEST_USER_PASS).toBeTruthy() + + await page.goto('/log-in') + await page.getByTestId('email').fill('playwright@thinknimble.com') + await page.getByTestId('password').fill(process.env.PLAYWRIGHT_TEST_USER_PASS ?? '') + await page.getByTestId('submit').click() + + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/sign-up.spec.ts b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/sign-up.spec.ts new file mode 100644 index 000000000..8c6316db7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/sign-up.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test' + +const PASSWORD = 'PASSWORD' + +function generateUniqueEmail() { + const timestamp = Date.now().toString(); + return `playwright-${timestamp}@thinknimble.com`; +} + +test('Login workflow', async ({ page }) => { + const uniqueEmail = generateUniqueEmail() + + await page.goto('/sign-up') + await page.getByTestId('first-name').fill('playwright') + await page.getByTestId('last-name').fill('e2e test') + await page + .getByTestId('email') + .fill(uniqueEmail) + await page.getByTestId('password').fill(PASSWORD) + await page.getByTestId('confirm-password').fill(PASSWORD) + await page.getByTestId('submit').click() + await expect(page.getByText('Welcome to')).toBeVisible() +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/test-login.cy.ts b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/test-login.cy.ts deleted file mode 100644 index 00c320f3b..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/specs/test-login.cy.ts +++ /dev/null @@ -1,13 +0,0 @@ -describe('Tests login workflow', () => { - it('Home page auto redirects to login', () => { - cy.visit('/') - cy.url().should('include', '/log-in') - }), - it('Home page auto redirects to login', () => { - cy.visit('/log-in') - cy.get('[data-cy=email]').type(Cypress.env('TEST_USER_EMAIL')) - cy.get('[data-cy=password]').type(Cypress.env('TEST_USER_PASS')) - cy.get('[data-cy="login-btn"]').click() - cy.url().should('include', '/home') - }) -}) diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/support/commands.js b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/support/commands.js deleted file mode 100644 index c1f5a772e..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/support/commands.js +++ /dev/null @@ -1,25 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This is will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/support/e2e.js b/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/support/e2e.js deleted file mode 100644 index d68db96df..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/react/tests/e2e/support/e2e.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tests/tsconfig.json b/{{cookiecutter.project_slug}}/clients/web/react/tests/tsconfig.json deleted file mode 100644 index aa9df84f7..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/react/tests/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "isolatedModules": false, - "types": ["cypress", "node"] - } -} diff --git a/{{cookiecutter.project_slug}}/clients/web/react/tsconfig.json b/{{cookiecutter.project_slug}}/clients/web/react/tsconfig.json index 6b33553df..76a139e18 100644 --- a/{{cookiecutter.project_slug}}/clients/web/react/tsconfig.json +++ b/{{cookiecutter.project_slug}}/clients/web/react/tsconfig.json @@ -20,7 +20,7 @@ "noEmit": true, "jsx": "react-jsx", "baseUrl": ".", - "types": ["cypress","node"] + "types": ["node"] }, "include": [ "src", diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/.env.local.example b/{{cookiecutter.project_slug}}/clients/web/vue3/.env.local.example index 7b4e6a951..46c1b5e8e 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/.env.local.example +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/.env.local.example @@ -1,5 +1,5 @@ # When running the backend manually locally with `python manage.py runserver` -# VITE_DEV_BACKEND_URL="http://localhost:8000" +# VITE_DEV_BACKEND_URL="http://127.0.0.1:8000" # When running locally with Docker # Disable VITE_DEV_BACKEND_URL here. Docker will find the backend on it's own diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/.gitignore b/{{cookiecutter.project_slug}}/clients/web/vue3/.gitignore index 4ec828127..291b779aa 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/.gitignore +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/.gitignore @@ -24,3 +24,7 @@ pnpm-debug.log* *.njsproj *.sln *.sw? +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/README.md b/{{cookiecutter.project_slug}}/clients/web/vue3/README.md index 6516bd004..4ce3fba4d 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/README.md +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/README.md @@ -20,7 +20,7 @@ Swap out the logo files in these locations: ## Initial Setup for non-Docker local First, create `.env.local` at the top-level of the **client** directory, and copy the contents of `.env.local.example` into it. -Un-comment the value of `VUE_APP_DEV_SERVER_BACKEND` that is appropriate for your situation. +Un-comment the value of `VITE_DEV_BACKEND_URL` that is appropriate for your situation. ``` npm install @@ -44,12 +44,19 @@ npm run build npm run test:unit ``` -### Run your end-to-end tests +### Run e2e tests with Playwright ``` npm run test:e2e ``` +Tests are run in headless mode meaning no browser will open up when running the tests. Results of the tests and test logs will be shown in the terminal. + +To open last HTML report run: + +``` +npx playwright show-report + ### Lints and fixes files ``` diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/cypress.config.js b/{{cookiecutter.project_slug}}/clients/web/vue3/cypress.config.js deleted file mode 100644 index 9d63d2ecb..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/cypress.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'cypress' -import pluginsFile from './tests/e2e/plugins' - -export default defineConfig({ - e2e: { - baseUrl: 'http://localhost:8080', - setupNodeEvents: pluginsFile, - supportFile: 'tests/e2e/support/e2e.js', - specPattern: 'tests/e2e/**/*.cy.{js,jsx,ts,tsx}', - }, -}) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/cypress.env.json.example b/{{cookiecutter.project_slug}}/clients/web/vue3/cypress.env.json.example deleted file mode 100644 index 3422f0062..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/cypress.env.json.example +++ /dev/null @@ -1,4 +0,0 @@ -{ - "TEST_USER_EMAIL": "cypress@example.com", - "TEST_USER_PASS": "" -} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/package.json b/{{cookiecutter.project_slug}}/clients/web/vue3/package.json index 1d298483e..654ef18fc 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/package.json +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/package.json @@ -6,11 +6,11 @@ "type": "module", "scripts": { "serve": "vite", - "build": "vue-tsc && vite build", + "build": "vite build", "preview": "vite preview", "test": "vitest run", + "test:e2e": "npx playwright test --reporter=html", "test:dev": "vitest", - "cypress:dev": "cypress open", "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src", "format:write": "prettier --write ./src", "format:check": "prettier --check ./src", @@ -27,20 +27,24 @@ "@tanstack/vue-query": "^5.28.9", "@thinknimble/vue3-alert-alert": "^0.0.8", "axios": "1.5.0", + "qs": "^6.13.0", "js-cookie": "3.0.5", - "vue": "^3.3.4", + "pinia": "^2.1.7", + "pinia-plugin-persistedstate": "^3.2.1", + "vue": "^3.4.30", "vue-router": "4.2.4", - "vuex": "4.1.0", - "vuex-persistedstate": "4.1.0", "zod": "3.21.4" }, "devDependencies": { + "@playwright/test": "^1.46.0", "@testing-library/vue": "^8.0.0", + "@types/node": "^22.2.0", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", - "@vitejs/plugin-vue": "^4.2.3", + "@vitejs/plugin-vue": "^5.0.0", + "@types/qs": "^6.9.15", "autoprefixer": "^10.4.15", - "cypress": "^13.5.1", + "dotenv": "^16.4.5", "eslint": "^8.49.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", @@ -50,7 +54,7 @@ "prettier": "3.0.3", "tailwindcss": "^3.3.3", "typescript": "^5.0.2", - "vite": "^4.4.5", + "vite": "^5.0.0", "vitest": "^0.34.6", "vue-tsc": "^1.8.27" } diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/playwright.config.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/playwright.config.ts new file mode 100644 index 000000000..5595fef3e --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test' +import dotenv from 'dotenv' +dotenv.config({ path: '.env' }) + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e/specs', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:8080', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/components/HelloWorld.vue b/{{cookiecutter.project_slug}}/clients/web/vue3/src/components/HelloWorld.vue index ae6789498..dec049020 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/components/HelloWorld.vue +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/components/HelloWorld.vue @@ -48,14 +48,6 @@ >unit-mocha
-
  • - e2e-cypress -
  • Essential Links

      diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/components/NavBar.vue b/{{cookiecutter.project_slug}}/clients/web/vue3/src/components/NavBar.vue index 5e4324f27..ce9ce28ba 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/components/NavBar.vue +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/components/NavBar.vue @@ -135,11 +135,11 @@ import { userApi } from '@/services/users' import { computed, ref } from 'vue' import { useRouter } from 'vue-router' -import { useStore } from 'vuex' +import { useUserStore } from '@/stores/user' export default { setup() { - const store = useStore() + const userStore = useUserStore() const router = useRouter() let mobileMenuOpen = ref(false) let profileMenuOpen = ref(false) @@ -155,15 +155,15 @@ export default { } finally { profileMenuOpen.value = false mobileMenuOpen.value = false - store.dispatch('setUser', null) + userStore.clearUser() router.push({ name: 'Home' }) } } return { logout, - isLoggedIn: computed(() => store.getters.isLoggedIn), - user: computed(() => store.getters.user), + isLoggedIn: computed(() => userStore.isLoggedIn), + user: computed(() => userStore.user), mobileMenuOpen, profileMenuOpen, } diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/composables/Users.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/src/composables/Users.ts index 5ab079ee5..c2d1310a3 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/composables/Users.ts +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/composables/Users.ts @@ -1,121 +1,120 @@ import { - AccountForm, - EmailForgotPasswordForm, - LoginForm, - LoginShape, - ResetPasswordForm, - ResetPasswordShape, - UserCreateShape, - UserShape, - userApi, - } from '@/services/users' - import { useMutation, useQueryClient } from '@tanstack/vue-query' - import { reactive, ref } from 'vue' - import { useRouter } from 'vue-router' - import { useStore } from 'vuex' - import { useAlert } from '@/composables/CommonAlerts' - - export function useUsers() { - const store = useStore() - const router = useRouter() - const qc = useQueryClient() - const loginForm = reactive(new LoginForm({})) - const forgotPasswordForm = reactive(new EmailForgotPasswordForm({})) - const resetPasswordForm = reactive(new ResetPasswordForm({})) - const registerForm = reactive(new AccountForm({})) - const loading = ref(false) - const { errorAlert, successAlert } = useAlert() - - const getCodeUidFromRoute = () => { - const { uid, token } = router.currentRoute.value.params - return { uid, token } - } + AccountForm, + EmailForgotPasswordForm, + LoginForm, + LoginShape, + ResetPasswordForm, + ResetPasswordShape, + UserCreateShape, + UserShape, + userApi, +} from '@/services/users' +import { useMutation, useQueryClient } from '@tanstack/vue-query' +import { reactive, ref } from 'vue' +import { useRouter } from 'vue-router' +import { useUserStore } from '@/stores/user' +import { useAlert } from '@/composables/CommonAlerts' - const { data: user, mutate: login } = useMutation({ - mutationFn: async (user: LoginShape) => { - return await userApi.csc.login(user) - }, - onMutate: async () => { - loading.value = true - }, - onError: (error: Error) => { - loading.value = false - console.log(error) - errorAlert('Invalid email or password') - }, - onSuccess: (data: UserShape) => { - loading.value = false - store.dispatch('setUser', data) - const redirectPath = router.currentRoute.value.query.redirect - if (redirectPath) { - router.push({ path: redirectPath as string }) - } else { - router.push({ name: 'Dashboard' }) - } - qc.invalidateQueries({ queryKey: ['user'] }) - }, - }) - const { mutate: requestPasswordReset } = useMutation({ - mutationFn: async (email: string) => { - await userApi.csc.requestPasswordReset({ email }) - }, - onError: (error: Error) => { - loading.value = false - console.log(error) - }, - onSuccess: () => { - loading.value = false - successAlert('Password reset link sent to your email') - qc.invalidateQueries({ queryKey: ['user'] }) - }, - }) - - const { mutate: resetPassword } = useMutation({ - mutationFn: async (data: ResetPasswordShape) => { - return await userApi.csc.resetPassword(data) - }, - onError: (error: Error) => { - loading.value = false - console.log(error) - errorAlert('There was an error attempting to reset password') - }, - onSuccess: (data: UserShape) => { - loading.value = false - store.dispatch('setUser', data) - router.push({ name: 'Dashboard' }) - qc.invalidateQueries({ queryKey: ['user'] }) - }, - }) - - const { mutate: register } = useMutation({ - mutationFn: async (data: UserCreateShape) => { - return await userApi.create(data) - }, - onError: (error: Error) => { - loading.value = false - console.log(error) - errorAlert('There was an error attempting to register') - }, - onSuccess: (data: UserShape ) => { - store.dispatch('setUser', data) +export function useUsers() { + const userStore = useUserStore() + const router = useRouter() + const qc = useQueryClient() + const loginForm = reactive(new LoginForm({})) + const forgotPasswordForm = reactive(new EmailForgotPasswordForm({})) + const resetPasswordForm = reactive(new ResetPasswordForm({})) + const registerForm = reactive(new AccountForm({})) + const loading = ref(false) + const { errorAlert, successAlert } = useAlert() + + const getCodeUidFromRoute = () => { + const { uid, token } = router.currentRoute.value.params + return { uid, token } + } + + const { data: user, mutate: login } = useMutation({ + mutationFn: async (user: LoginShape) => { + return await userApi.csc.login(user) + }, + onMutate: async () => { + loading.value = true + }, + onError: (error: Error) => { + loading.value = false + console.log(error) + errorAlert('Invalid email or password') + }, + onSuccess: (data: UserShape) => { + loading.value = false + userStore.updateUser(data) + const redirectPath = router.currentRoute.value.query.redirect + if (redirectPath) { + router.push({ path: redirectPath as string }) + } else { router.push({ name: 'Dashboard' }) - qc.invalidateQueries({ queryKey: ['user'] }) - loading.value = false - }, - }) - - return { - loginForm, - forgotPasswordForm, - resetPasswordForm, - loading, - login, - requestPasswordReset, - resetPassword, - user, - register, - registerForm, - getCodeUidFromRoute, - } + } + qc.invalidateQueries({ queryKey: ['user'] }) + }, + }) + const { mutate: requestPasswordReset } = useMutation({ + mutationFn: async (email: string) => { + await userApi.csc.requestPasswordReset({ email }) + }, + onError: (error: Error) => { + loading.value = false + console.log(error) + }, + onSuccess: () => { + loading.value = false + successAlert('Password reset link sent to your email') + qc.invalidateQueries({ queryKey: ['user'] }) + }, + }) + + const { mutate: resetPassword } = useMutation({ + mutationFn: async (data: ResetPasswordShape) => { + return await userApi.csc.resetPassword(data) + }, + onError: (error: Error) => { + loading.value = false + console.log(error) + errorAlert('There was an error attempting to reset password') + }, + onSuccess: (data: UserShape) => { + loading.value = false + userStore.updateUser(data) + router.push({ name: 'Dashboard' }) + qc.invalidateQueries({ queryKey: ['user'] }) + }, + }) + + const { mutate: register } = useMutation({ + mutationFn: async (data: UserCreateShape) => { + return await userApi.create(data) + }, + onError: (error: Error) => { + loading.value = false + console.log(error) + errorAlert('There was an error attempting to register') + }, + onSuccess: (data: UserShape) => { + userStore.updateUser(data) + router.push({ name: 'Dashboard' }) + qc.invalidateQueries({ queryKey: ['user'] }) + loading.value = false + }, + }) + + return { + loginForm, + forgotPasswordForm, + resetPasswordForm, + loading, + login, + requestPasswordReset, + resetPassword, + user, + register, + registerForm, + getCodeUidFromRoute, } - \ No newline at end of file +} diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/main.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/src/main.ts index 64f5bfbaa..d53d22999 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/main.ts +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/main.ts @@ -1,15 +1,16 @@ import { createApp } from 'vue' +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +import AlertPlugin from '@thinknimble/vue3-alert-alert' +import { VueQueryPlugin } from '@tanstack/vue-query' + import './main.css' import App from './App.vue' -import store from './store' import router from './router' + import '@thinknimble/vue3-alert-alert/dist/vue3-alert-alert.css' -import AlertPlugin from '@thinknimble/vue3-alert-alert' -import { VueQueryPlugin } from '@tanstack/vue-query' -createApp(App) - .use(AlertPlugin, {}) - .use(VueQueryPlugin) - .use(store) - .use(router) - .mount('#app') +const pinia = createPinia() +pinia.use(piniaPluginPersistedstate) + +createApp(App).use(pinia).use(AlertPlugin, {}).use(VueQueryPlugin).use(router).mount('#app') diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/services/AxiosClient.js b/{{cookiecutter.project_slug}}/clients/web/vue3/src/services/AxiosClient.js index 1075e521c..d8ad9395b 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/services/AxiosClient.js +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/services/AxiosClient.js @@ -1,6 +1,7 @@ import axios from 'axios' -import store from '@/store' +import { useUserStore } from '@/stores/user' import CSRF from '@/services/csrf' +import qs from 'qs' /** * Get the axios API client. @@ -27,11 +28,15 @@ class ApiService { headers: { ...CSRF.getHeaders(), }, + paramsSerializer: (params) => { + return qs.stringify(params, { arrayFormat: 'comma' }) + }, }) ApiService.session.interceptors.request.use( async (config) => { - if (store.getters.isLoggedIn) { - config.headers['Authorization'] = `Token ${store.getters.token}` + const userStore = useUserStore() + if (userStore.isLoggedIn) { + config.headers['Authorization'] = `Token ${userStore.token}` } return config }, diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/services/auth.js b/{{cookiecutter.project_slug}}/clients/web/vue3/src/services/auth.js index 7f01db89f..79978a00e 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/services/auth.js +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/services/auth.js @@ -1,4 +1,4 @@ -import store from '@/store' +import { useUserStore } from '@/stores/user' /** * Route Guard. @@ -6,7 +6,8 @@ import store from '@/store' * If not logged in, a user will be redirected to the login page. */ export function requireAuth(to, from, next) { - if (!store.getters.isLoggedIn) { + const userStore = useUserStore() + if (!userStore.isLoggedIn) { next({ name: 'Login', query: { redirect: to.fullPath }, @@ -22,7 +23,8 @@ export function requireAuth(to, from, next) { * If logged in, a user will be redirected to the dashboard page. */ export function requireNoAuth(to, from, next) { - if (store.getters.isLoggedIn) { + const userStore = useUserStore() + if (userStore.isLoggedIn) { next({ name: 'Dashboard', }) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/store/index.js b/{{cookiecutter.project_slug}}/clients/web/vue3/src/store/index.js deleted file mode 100644 index 7e05bac59..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/store/index.js +++ /dev/null @@ -1,49 +0,0 @@ -import { createStore } from 'vuex' -import createPersistedState from 'vuex-persistedstate' -import { SET_USER } from './mutation-types' - -const STORAGE_HASH = '{{ random_ascii_string(10) }}' -export const STORAGE_KEY = `{{ cookiecutter.project_slug }}-${STORAGE_HASH}` - -const state = { - user: null, -} - -const mutations = { - [SET_USER]: (state, payload) => { - state.user = payload - }, -} - -const actions = { - setUser({ commit }, user) { - commit(SET_USER, user) - }, -} - -const getters = { - isLoggedIn: (state) => { - return !!state.user - }, - user: (state) => { - return state.user - }, - token: (state) => { - return state.user ? state.user.token : null - }, -} - -const store = createStore({ - state, - mutations, - actions, - getters, - modules: {}, - plugins: [ - createPersistedState({ - key: STORAGE_KEY, - }), - ], -}) - -export default store diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/store/mutation-types.js b/{{cookiecutter.project_slug}}/clients/web/vue3/src/store/mutation-types.js deleted file mode 100644 index 0c16d58dc..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/store/mutation-types.js +++ /dev/null @@ -1 +0,0 @@ -export const SET_USER = 'SET_USER' diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/stores/user.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/src/stores/user.ts new file mode 100644 index 000000000..3ac0bb819 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/stores/user.ts @@ -0,0 +1,35 @@ +// Pinia Store +import { defineStore } from 'pinia' +import { UserShape } from '@/services/users' + +const STORAGE_HASH = '{{ random_ascii_string(10) }}' +export const STORAGE_KEY = `{{ cookiecutter.project_slug }}-${STORAGE_HASH}` + +interface State { + user: UserShape | null +} + +export const useUserStore = defineStore('user', { + state: (): State => ({ + user: null, + }), + persist: { + key: STORAGE_KEY, + }, + getters: { + isLoggedIn: (state) => { + return !!state.user + }, + token: (state) => { + return state.user ? state.user.token : null + }, + }, + actions: { + updateUser(payload: UserShape) { + this.user = payload + }, + clearUser() { + this.$reset() + }, + }, +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/views/Login.vue b/{{cookiecutter.project_slug}}/clients/web/vue3/src/views/Login.vue index cda83b7d6..80d1adc7e 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/views/Login.vue +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/views/Login.vue @@ -14,7 +14,6 @@ :errors="form.email.errors" @blur="form.email.validate()" type="email" - data-cy="email" label="Email address" placeholder="Enter email..." :id="form.email.id" @@ -26,7 +25,6 @@ :errors="form.password.errors" @blur="form.password.validate()" type="password" - data-cy="password" placeholder="Enter password..." label="Password" autocomplete="current-password" @@ -56,7 +54,7 @@
      - +
      diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/src/views/Signup.vue b/{{cookiecutter.project_slug}}/clients/web/vue3/src/views/Signup.vue index ac5d7a008..32ae6e112 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/src/views/Signup.vue +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/src/views/Signup.vue @@ -72,13 +72,7 @@
      -
      diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/.eslintrc.js b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/.eslintrc.js deleted file mode 100644 index a3e436bc3..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/.eslintrc.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - plugins: ['cypress'], - env: { - mocha: true, - 'cypress/globals': true, - }, - rules: { - strict: 'off', - }, -} diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/plugins/index.js b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/plugins/index.js deleted file mode 100644 index b150c40be..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/plugins/index.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable arrow-body-style */ -// https://docs.cypress.io/guides/guides/plugins-guide.html - -// if you need a custom webpack configuration you can uncomment the following import -// and then use the `file:preprocessor` event -// as explained in the cypress docs -// https://docs.cypress.io/api/plugins/preprocessors-api.html#Examples - -// /* eslint-disable import/no-extraneous-dependencies, global-require */ -// const webpack = require('@cypress/webpack-preprocessor') - -export default (on, config) => { - // on('file:preprocessor', webpack({ - // webpackOptions: require('@vue/cli-service/webpack.config'), - // watchOptions: {} - // })) - - return Object.assign({}, config, { - fixturesFolder: 'tests/e2e/fixtures', - screenshotsFolder: 'tests/e2e/screenshots', - videosFolder: 'tests/e2e/videos', - }) -} diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/home.spec.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/home.spec.ts new file mode 100644 index 000000000..9b551d4f7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/home.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test' + +test.beforeEach(async ({ page }) => { + await page.goto('/') +}) + +test('Has Welcome text', async ({ page }) => { + await expect(page.getByText('Welcome')).toBeVisible() +}) + +test('Login and signup buttons are visible', async ({ page }) => { + await expect(page.getByText('Login')).toBeVisible() + await expect(page.getByText('Signup')).toBeVisible() +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/login.spec.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/login.spec.ts new file mode 100644 index 000000000..a6d54d1c0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/login.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test' + +test('Login workflow', async ({ page }) => { + expect(process.env.PLAYWRIGHT_TEST_USER_PASS).toBeTruthy() + + await page.goto('/login') + await page.getByPlaceholder('Enter email...').fill('playwright@thinknimble.com') + await page.getByPlaceholder('Enter password...').fill(process.env.PLAYWRIGHT_TEST_USER_PASS ?? '') + await page.getByRole('button', { name: 'Log in' }).click() + + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/sign-up.spec.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/sign-up.spec.ts new file mode 100644 index 000000000..d230bd5c6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/sign-up.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test' + +const PASSWORD = 'PASSWORD' + +function generateUniqueEmail() { + const timestamp = Date.now().toString(); + return `playwright-${timestamp}@thinknimble.com`; +} + +test('Sign up workflow', async ({ page }) => { + const uniqueEmail = generateUniqueEmail() + + await page.goto('/signup') + await page.getByPlaceholder('Enter first name...').fill('playwright') + await page.getByPlaceholder('Enter last name...').fill('e2e test') + await page + .getByPlaceholder('Enter email...') + .fill(uniqueEmail) + await page.getByPlaceholder('Enter password...').fill(PASSWORD) + await page.getByPlaceholder('Confirm Password').fill(PASSWORD) + await page.getByRole('button', { name: 'Sign up' }).click() + + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() +}) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/test-login.cy.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/test-login.cy.ts deleted file mode 100644 index 95dd4410c..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/test-login.cy.ts +++ /dev/null @@ -1,15 +0,0 @@ -describe('Tests login workflow', () => { - it('Home page has link to login', () => { - cy.visit('/') - cy.get('[data-cy=login]').click() - cy.url().should('include', '/login') - }) - - it('Filling in email and passwords goes to', () => { - cy.visit('/login') - cy.get('[data-cy=email]').type(Cypress.env('TEST_USER_EMAIL')) - cy.get('[data-cy=password]').type(Cypress.env('TEST_USER_PASS')) - cy.contains('[data-cy=submit]', 'Log in').click() - cy.url().should('include', '/dashboard') - }) -}) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/test-password-reset.cy.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/test-password-reset.cy.ts deleted file mode 100644 index b0ae26b6f..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/specs/test-password-reset.cy.ts +++ /dev/null @@ -1,15 +0,0 @@ -describe('Tests password reset workflow', () => { - it('Login page has link to reset password', () => { - cy.visit('/login') - cy.contains('[data-cy=password-reset]', 'Forgot password?') - }) - - it('Submitting email doesnt throw error', () => { - cy.visit('/login') - cy.contains('[data-cy=password-reset]', 'Forgot password?').click() - cy.url().should('include', '/password/request-reset/') - cy.get('[data-cy=email]').type(Cypress.env('TEST_USER_EMAIL')) - cy.contains('[data-cy=submit]', 'Request Password Reset').click() - cy.get('[data-cy=submit-success]').should('contain.text', 'Your request has been submitted') - }) -}) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/support/commands.js b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/support/commands.js deleted file mode 100644 index c1f5a772e..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/support/commands.js +++ /dev/null @@ -1,25 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This is will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/support/e2e.js b/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/support/e2e.js deleted file mode 100644 index d68db96df..000000000 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/tests/e2e/support/e2e.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/tsconfig.json b/{{cookiecutter.project_slug}}/clients/web/vue3/tsconfig.json index be6bcf6c3..69d94d90f 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/tsconfig.json +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/tsconfig.json @@ -24,7 +24,6 @@ "noFallthroughCasesInSwitch": true, "types": [ "vitest/globals", - "cypress" ], "paths": { "@/*": [ diff --git a/{{cookiecutter.project_slug}}/clients/web/vue3/vite.config.ts b/{{cookiecutter.project_slug}}/clients/web/vue3/vite.config.ts index ccb3b6040..25643e2b1 100644 --- a/{{cookiecutter.project_slug}}/clients/web/vue3/vite.config.ts +++ b/{{cookiecutter.project_slug}}/clients/web/vue3/vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig(({ mode }) => { }, }, port: 8080, + host: true, }, test: { // enable jest-like global test APIs diff --git a/{{cookiecutter.project_slug}}/compose/server/Dockerfile b/{{cookiecutter.project_slug}}/compose/server/Dockerfile index 0d7ed6c16..1bb2b7b69 100644 --- a/{{cookiecutter.project_slug}}/compose/server/Dockerfile +++ b/{{cookiecutter.project_slug}}/compose/server/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim-buster +FROM python:3.12-slim-bullseye # These environment values help with watching for file changes ENV PYTHONUNBUFFERED 1 diff --git a/{{cookiecutter.project_slug}}/package.json b/{{cookiecutter.project_slug}}/package.json index 75f9bcedf..ded251f81 100644 --- a/{{cookiecutter.project_slug}}/package.json +++ b/{{cookiecutter.project_slug}}/package.json @@ -9,7 +9,7 @@ }, "cacheDirectories": [ "client/node_modules", - ".cache/Cypress" + ".cache/ms-playwright" ], "dependencies": {} } diff --git a/{{cookiecutter.project_slug}}/pytest.ini b/{{cookiecutter.project_slug}}/pytest.ini index 8eed4bed2..e947626ee 100644 --- a/{{cookiecutter.project_slug}}/pytest.ini +++ b/{{cookiecutter.project_slug}}/pytest.ini @@ -2,7 +2,7 @@ DJANGO_SETTINGS_MODULE = {{ cookiecutter.project_slug }}.test_settings python_files = tests.py test_*.py *_tests.py addopts = --strict-markers --no-migrations -mccabe-complexity=10 +mccabe-complexity=8 filterwarnings = ignore::DeprecationWarning diff --git a/{{cookiecutter.project_slug}}/resources/app.config.vars.template.txt b/{{cookiecutter.project_slug}}/resources/app.config.vars.template.txt new file mode 100644 index 000000000..99bbfecbc --- /dev/null +++ b/{{cookiecutter.project_slug}}/resources/app.config.vars.template.txt @@ -0,0 +1,8 @@ +REPLACE_WITH_EXPO_APP_NAME= +REPLACE_WITH__EXPO_APP_SLUG= +REPLACE_WITH_EXPO_OWNER= +REPLACE_WITH_EXPO_APP_ID= +REPLACE_WITH_SENTRY_ORG= +REPLACE_WITH_IOS_BUNDLE_ID= +REPLACE_WITH_ANDROID_PACKAGE_ID= +REPLACE_WITH_EXPO_APP_ID= \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/resources/eas.vars.template.txt b/{{cookiecutter.project_slug}}/resources/eas.vars.template.txt new file mode 100644 index 000000000..f8cd02ede --- /dev/null +++ b/{{cookiecutter.project_slug}}/resources/eas.vars.template.txt @@ -0,0 +1,17 @@ +REPLACE_WITH_LOCAL_BACKEND_SERVER_URL= +REPLACE_WITH_STAGING_BACKEND_SERVER_URL= +REPLACE_WITH_PRODUCTION_BACKEND_SERVER_URL= +REPLACE_WITH_REVIEW_APP_BACKEND_SERVER_URL= +REPLACE_WITH_LOCAL_ROLLBAR_TOKEN= +REPLACE_WITH_STAGING_ROLLBAR_TOKEN= +REPLACE_WITH_PRODUCTION_ROLLBAR_TOKEN= +REPLACE_WITH_REVIEW_APP_ROLLBAR_TOKEN= +REPLACE_WITH_REVIEW_APP_SENTRY_DSN= +REPLACE_WITH_LOCAL_SENTRY_DSN= +REPLACE_WITH_STAGING_SENTRY_DSN= +REPLACE_WITH_PRODUCTION_SENTRY_DSN= +REPLACE_WITH_APPSTORE_CONNECT_APP_NAME= +REPLACE_WITH_APPLE_ID_EMAIL_SIGNING_APP= +REPLACE_WITH_APPLE_TEAM_ID= +REPLACE_WITH_APPLESTORE_CONNECT_ID= +REPLACE_WITH_REVIEW_APP_SENTRY_PROJECT_NAME= diff --git a/{{cookiecutter.project_slug}}/runtime.txt b/{{cookiecutter.project_slug}}/runtime.txt index 119ff1023..32905d6e0 100644 --- a/{{cookiecutter.project_slug}}/runtime.txt +++ b/{{cookiecutter.project_slug}}/runtime.txt @@ -1 +1 @@ -python-3.10.7 +python-3.12.7 diff --git a/{{cookiecutter.project_slug}}/scripts/setup_mobile_config.sh b/{{cookiecutter.project_slug}}/scripts/setup_mobile_config.sh new file mode 100644 index 000000000..8af29dc03 --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/setup_mobile_config.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# for debugging + +# set -e +# set -x + +# base_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +config_file=$1 + +defaults_file=$2 + + +declare -a config_vars +declare -a merged_arr + +reset_config_vars() { + unset config_vars +} +trap reset_config_vars EXIT + +while IFS= read -r line; do + config_vars+=("$line") +done < <(grep -o "REPLACE_WITH_[A-Z_]*" "$config_file") + + +for i in "${config_vars[@]}"; do + value=$(grep -o "$i=.*" "$defaults_file") || echo "" + if [[ -z $value ]]; then + echo "Skipping $i as it does not exist in $defaults_file" + continue + fi + + value=${value#*=} + value=$(printf '%q' "$value") + if [[ -n $value ]]; then + sed -i.bak "s#<$i>#$value#g" "$config_file" + else + echo "Skipping $i as it does not exist in $defaults_file" + fi +done \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/client/templates/drf-yasg/swagger-ui.html b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/client/templates/drf-yasg/swagger-ui.html deleted file mode 100644 index 03bae094a..000000000 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/client/templates/drf-yasg/swagger-ui.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "drf-yasg/swagger-ui.html" %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/common/models.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/common/models.py index 9f4b0ac93..44edb9e2c 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/common/models.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/common/models.py @@ -16,4 +16,4 @@ class Meta: abstract = True def __str__(self): - return "ah yes" + return "__str__ not defined for this model" diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/admin.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/admin.py index 509f67629..27490cb92 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/admin.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/admin.py @@ -5,6 +5,7 @@ from {{ cookiecutter.project_slug }}.common.admin.filters import AutocompleteAdminMedia, AutocompleteFilter +from .forms import GroupAdminForm from .models import User @@ -23,7 +24,7 @@ class CustomUserAdmin(UserAdmin): ) }, ), - ("Admin Options", {"classes": ("collapse",), "fields": ("is_staff",)}), + ("Admin Options", {"classes": ("collapse",), "fields": ("is_active", "is_staff", "is_superuser", "groups")}), ) add_fieldsets = ( ( @@ -34,7 +35,15 @@ class CustomUserAdmin(UserAdmin): }, ), ) - list_display = ("is_active", "email", "first_name", "last_name") + list_display = ( + "email", + "permissions", + "is_active", + "is_staff", + "is_superuser", + "first_name", + "last_name", + ) list_display_links = ( "is_active", "email", @@ -52,14 +61,18 @@ class CustomUserAdmin(UserAdmin): "is_staff", "is_superuser", ) - + filter_horizontal = ("groups",) ordering = [] + def permissions(self, obj): + return [g.name for g in obj.groups.all()] + class Media(AutocompleteAdminMedia): pass class CustomGroupAdmin(GroupAdmin): + form = GroupAdminForm list_filter = (("permissions", AutocompleteFilter),) class Media(AutocompleteAdminMedia): diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/factories.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/factories.py index 33f9bc7dc..b5072fc54 100755 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/factories.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/factories.py @@ -7,6 +7,8 @@ class UserFactory(factory.Factory): email = factory.faker.Faker("email") password = factory.PostGenerationMethodCall("set_password", "password") + first_name = factory.faker.Faker("first_name") + last_name = factory.faker.Faker("last_name") class Meta: model = User diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/forms.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/forms.py index 566e54ce2..2cbffa367 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/forms.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/forms.py @@ -1,5 +1,31 @@ from django import forms +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group + +User = get_user_model() class PreviewTemplateForm(forms.Form): _send_to = forms.EmailField(label="Send by email to", widget=forms.EmailInput(attrs={"size": 60})) + + +class GroupAdminForm(forms.ModelForm): + users = forms.ModelMultipleChoiceField(queryset=User.objects.all(), required=False, widget=FilteredSelectMultiple("users", False)) + + def __init__(self, *args, **kwargs): + super(GroupAdminForm, self).__init__(*args, **kwargs) + if self.instance.pk: + self.fields["users"].initial = self.instance.user_set.all() + + def save_m2m(self): + self.instance.user_set.set(self.cleaned_data["users"]) + + def save(self, *args, **kwargs): + instance = super(GroupAdminForm, self).save() + self.save_m2m() + return instance + + class Meta: + model = Group + exclude = [] diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/management/commands/create_test_data.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/management/commands/create_test_data.py index cb37c5541..6bbd69c4f 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/management/commands/create_test_data.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/management/commands/create_test_data.py @@ -13,11 +13,11 @@ class Command(BaseCommand): def handle(self, *args, **kwargs): logger.info(f"Starting management command {__name__}") superuser_password = config("DJANGO_SUPERUSER_PASSWORD") - cypress_password = config("CYPRESS_TEST_USER_PASS") + playwright_password = config("PLAYWRIGHT_TEST_USER_PASS") get_user_model().objects.create_superuser( email="admin@thinknimble.com", password=superuser_password, first_name="Admin", last_name="ThinkNimble" ) get_user_model().objects.create_user( - email="cypress@example.com", password=cypress_password, first_name="Cypress", last_name="E2E_test" + email="playwright@thinknimble.com", password=playwright_password, first_name="Playwright", last_name="E2E_test" ) logger.info(f"Finished management command {__name__}") diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/models.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/models.py index c7cceee33..e803538e2 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/models.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/models.py @@ -11,11 +11,26 @@ logger = logging.getLogger(__name__) +class UserQuerySet(models.QuerySet): + def for_user(self, user): + if not user or user.is_anonymous: + return self.none() + elif user.is_staff: + return self.all() + return self.filter(pk=user.pk) + + class UserManager(BaseUserManager): """Custom User model manager, eliminating the 'username' field.""" use_in_migrations = True + def get_queryset(self): + return UserQuerySet(self.model, using=self.db) + + def for_user(self, user): + return self.get_queryset().for_user(user) + def _create_user(self, email, password, **extra_fields): """ Create and save a User with the given email and password. diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/permissions.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/permissions.py index eb6a2282c..0e67f48b4 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/permissions.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/permissions.py @@ -1,8 +1,8 @@ from rest_framework import permissions -class CreateOnlyPermissions(permissions.BasePermission): - def has_permission(self, request, view): - if view.action == "create": - return True - return False +class HasUserPermissions(permissions.BasePermission): + """Admins should be able to perform any action, regular users should be able to edit and delete self.""" + + def has_object_permission(self, request, view, obj): + return request.user.is_authenticated and (request.user.is_staff or obj == request.user) diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/serializers.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/serializers.py index 27dbc64e3..723f88479 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/serializers.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/serializers.py @@ -16,6 +16,7 @@ class Meta: "last_name", "full_name", ) + read_only_fields = ["email"] class UserLoginSerializer(serializers.ModelSerializer): diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/tests.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/tests.py index e3cc149af..fb054748a 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/tests.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/tests.py @@ -4,6 +4,7 @@ from django.contrib.auth import authenticate from django.test import override_settings from django.test.client import RequestFactory +from rest_framework import status from rest_framework.response import Response from .models import User @@ -34,6 +35,13 @@ def test_create_user(): assert not user.is_superuser +@pytest.mark.django_db +def test_create_user_api(api_client): + data = {"email": "example@example.com", "password": "password", "first_name": "Test", "last_name": "User"} + res = api_client.post("/api/users/", data, format="json") + assert res.status_code == status.HTTP_201_CREATED, res.data + + @pytest.mark.django_db def test_create_superuser(): superuser = User.objects.create_superuser(email="test@example.com", password="password", first_name="Leslie", last_name="Burke") @@ -50,7 +58,59 @@ def test_create_user_from_factory(sample_user): @pytest.mark.django_db def test_user_can_login(api_client, sample_user): res = api_client.post("/api/login/", {"email": sample_user.email, "password": "password"}, format="json") - assert res.status_code == 200 + assert res.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_wrong_email(api_client, sample_user): + res = api_client.post("/api/login/", {"email": "wrong@example.com", "password": "password"}, format="json") + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_wrong_password(api_client, sample_user): + res = api_client.post("/api/login/", {"email": sample_user.email, "password": "wrong"}, format="json") + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_get_user(api_client, sample_user): + api_client.force_authenticate(sample_user) + res = api_client.get(f"/api/users/{sample_user.pk}/") + assert res.status_code == status.HTTP_200_OK + assert res.data["email"] == sample_user.email + + +@pytest.mark.django_db +def test_get_other_user(api_client, sample_user, user_factory): + api_client.force_authenticate(sample_user) + other_user = user_factory() + other_user.save() + res = api_client.get(f"/api/users/{other_user.pk}/") + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_update_user(api_client, sample_user): + existing_email = sample_user.email + api_client.force_authenticate(sample_user) + data = {"email": "example@example.com", "password": "password", "first_name": "Test", "last_name": "User"} + res = api_client.put(f"/api/users/{sample_user.pk}/", data, format="json") + assert res.status_code == status.HTTP_200_OK + sample_user.refresh_from_db() + # Email should NOT have changed + assert sample_user.email == existing_email + assert sample_user.first_name == data["first_name"] == res.data["first_name"] + assert sample_user.last_name == data["last_name"] == res.data["last_name"] + + +@pytest.mark.django_db +def test_delete_user(api_client, sample_user): + api_client.force_authenticate(sample_user) + res = api_client.delete(f"/api/users/{sample_user.pk}/") + assert res.status_code == status.HTTP_204_NO_CONTENT + sample_user.refresh_from_db() + assert sample_user.is_active is False @pytest.mark.use_requests @@ -67,7 +127,7 @@ def test_password_reset(caplog, api_client, sample_user): # Verify the link works for reseting the password response = api_client.post(password_reset_url, data={"password": "new_password"}, format="json") - assert response.status_code == 200 + assert response.status_code == status.HTTP_200_OK # New Password should now work for authentication serializer = UserLoginSerializer(data={"email": sample_user.email, "password": "new_password"}) @@ -87,7 +147,7 @@ class TestPreviewTemplateView: @override_settings(DEBUG=False) def test_disabled_if_not_debug(self, client): response = client.post(self.url) - assert response.status_code == 404 + assert response.status_code == status.HTTP_404_NOT_FOUND @override_settings(DEBUG=True) def test_enabled_if_debug(self, client): @@ -98,19 +158,19 @@ def test_enabled_if_debug(self, client): @override_settings(DEBUG=True) def test_no_template_provided(self, client): response = client.post(self.url, data={"_send_to": "someone@example.com"}) - assert response.status_code == 400 + assert response.status_code == status.HTTP_400_BAD_REQUEST assert any("You must provide a template name" in e for e in response.json()) @override_settings(DEBUG=True) def test_invalid_template_provided(self, client): response = client.post(f"{self.url}?template=SOME_TEMPLATE/WHICH_DOES_NOT/EXIST", data={"_send_to": "someone@example.com"}) - assert response.status_code == 400 + assert response.status_code == status.HTTP_400_BAD_REQUEST assert any("Invalid template name" in e for e in response.json()) @override_settings(DEBUG=True) def test_missing_send_to(self, client): response = client.post(f"{self.url}?template=SOME_TEMPLATE/WHICH_DOES_NOT/EXIST") - assert response.status_code == 400 + assert response.status_code == status.HTTP_400_BAD_REQUEST assert "This field is required." in response.json()["_send_to"] def test_parse_value_without_model(self): diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py index e0a214f0f..b6365da38 100755 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py @@ -18,11 +18,11 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from {{ cookiecutter.project_slug }}.core.forms import PreviewTemplateForm from {{ cookiecutter.project_slug }}.utils.emails import send_html_email +from .forms import PreviewTemplateForm from .models import User -from .permissions import CreateOnlyPermissions +from .permissions import HasUserPermissions from .serializers import UserLoginSerializer, UserRegistrationSerializer, UserSerializer logger = logging.getLogger(__name__) @@ -50,44 +50,45 @@ def post(self, request, *args, **kwargs): return Response(response_data) -class UserViewSet( - viewsets.GenericViewSet, - mixins.RetrieveModelMixin, - mixins.ListModelMixin, - mixins.UpdateModelMixin, -): - queryset = User.objects.all() +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + queryset = User.objects serializer_class = UserSerializer # No auth required to create user # Auth required for all other actions - permission_classes = (permissions.IsAuthenticated | CreateOnlyPermissions,) + permission_classes = (HasUserPermissions,) - @transaction.atomic - def create(self, request, *args, **kwargs): + def get_queryset(self): """ - Endpoint to create/register a new user. + Users should only find themselves by default """ + return super().get_queryset().for_user(self.request.user) + + @transaction.atomic + def create(self, request, *args, **kwargs): serializer = UserRegistrationSerializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save() # This calls .create() on serializer - user = serializer.instance - + user = serializer.save() # Log-in user and re-serialize response response_data = UserLoginSerializer.login(user, request) return Response(response_data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): - """ - Endpoint to create/register a new user. - """ serializer = UserSerializer(data=request.data, instance=self.get_object(), partial=True) - serializer.is_valid(raise_exception=True) serializer.save() - user = serializer.data + return Response(serializer.data, status=status.HTTP_200_OK) - return Response(user, status=status.HTTP_200_OK) + def destroy(self, request, *args, **kwargs): + """ + When deleting a user's account, just disable their account first + The user may have a regret and try to get their account back + A background job should then properly delete the data after X days + """ + user = self.get_object() + user.is_active = False + user.save() + return Response(status=status.HTTP_204_NO_CONTENT) @api_view(["post"]) @@ -116,7 +117,7 @@ def request_reset_link(request, *args, **kwargs): def reset_password(request, *args, **kwargs): user_id = kwargs.get("uid") token = kwargs.get("token") - user = User.objects.filter(id=user_id).first() + user = User.objects.filter(pk=user_id).first() if not user or not token: raise ValidationError(detail={"non-field-error": "Invalid or expired token"}) is_valid = default_token_generator.check_token(user, token) @@ -125,6 +126,7 @@ def reset_password(request, *args, **kwargs): logger.info(f"Resetting password for user {user_id}") user.set_password(request.data.get("password")) user.save() + # COMMENT THIS WHEN USING THE PASSWORD RESET FLOW ON WEB ONLY FOR MOBILE - PARI BAKER response_data = UserLoginSerializer.login(user, request) return Response(response_data, status=status.HTTP_200_OK) diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/settings.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/settings.py index 9ede70a09..f4b6f713e 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/settings.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/settings.py @@ -38,6 +38,9 @@ if CURRENT_DOMAIN not in ALLOWED_HOSTS: ALLOWED_HOSTS.append(CURRENT_DOMAIN) +# Used by the corsheaders app/middleware (django-cors-headers) to allow multiple domains to access the backend +CORS_ALLOWED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS] + # Application definition INSTALLED_APPS = [ @@ -57,7 +60,6 @@ # Third Party "corsheaders", "drf_spectacular", - "django_nose", "rest_framework", "rest_framework.authtoken", "dj_rest_auth", @@ -306,7 +308,6 @@ if not IN_DEV: SECURE_SSL_REDIRECT = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") - MIDDLEWARE += ["django.middleware.security.SecurityMiddleware"] # # Custom logging configuration @@ -387,14 +388,6 @@ def filter(self, record): LOGGING["loggers"]["django"]["handlers"].append("rollbar") LOGGING["loggers"]["{{ cookiecutter.project_slug }}"]["handlers"].append("rollbar") -# Popular testing framework that allows logging to stdout while running unit tests -TEST_RUNNER = "django_nose.NoseTestSuiteRunner" - -CORS_ALLOWED_ORIGINS = ["https://{{ cookiecutter.project_slug|replace('_', '-') }}-staging.herokuapp.com", "https://{{ cookiecutter.project_slug|replace('_', '-') }}.herokuapp.com"] -{% if cookiecutter.client_app.lower() != 'none' -%} -CORS_ALLOWED_ORIGINS.append("http://localhost:8080") -{% endif -%} - SWAGGER_SETTINGS = { "LOGIN_URL": "/login", "USE_SESSION_AUTH": False, @@ -406,3 +399,7 @@ def filter(self, record): "SHOW_REQUEST_HEADERS": True, "OPERATIONS_SORTER": "alpha", } + +SPECTACULAR_SETTINGS = { + "COMPONENT_SPLIT_REQUEST": True, # Needed for file upload to work +} diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/test_settings.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/test_settings.py index 0d2d545f3..c6b2f64ea 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/test_settings.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/test_settings.py @@ -1,7 +1,7 @@ from decouple import config -from {{ cookiecutter.project_slug }}.settings import LOGGING from {{ cookiecutter.project_slug }}.settings import * # noqa +from {{ cookiecutter.project_slug }}.settings import LOGGING # Override staticfiles setting to avoid cache issues with whitenoise Manifest staticfiles storage # See: https://stackoverflow.com/a/69123932 diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/urls.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/urls.py index 3b962cf51..bf86ab21e 100755 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/urls.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/urls.py @@ -1,6 +1,9 @@ from django.contrib import admin from django.urls import include, path +admin.site.site_header = "{{ cookiecutter.project_name }} Admin" +admin.site.site_title = "{{ cookiecutter.project_name }}" + urlpatterns = [ path(r"staff/", admin.site.urls), path(r"", include("{{ cookiecutter.project_slug }}.common.favicon_urls")), diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/utils/tests.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/utils/tests.py index 86d8dbb95..427cd5ad7 100644 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/utils/tests.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/utils/tests.py @@ -37,4 +37,4 @@ def test_get_site_url_negative(settings, custom_settings): def test_password_reset_email_link(user): context = user.reset_password_context() html_body = get_html_body("registration/password_reset.html", context) - assert f"{ context['site_url'] }/password/reset/confirm/{ context['user'].id }/{ context['token'] }" in html_body + assert f"{context['site_url']}/password/reset/confirm/{context['user'].id}/{context['token']}" in html_body