diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37586b1..6c3218e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + AWS_CFN_TEMPLATE: template.yml + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_DEV }} + ENV_FILE: ${{ secrets.ENV_CI }} + jobs: build: name: 'Build' @@ -29,6 +35,10 @@ jobs: - name: Install Dependencies run: npm ci + - name: 'Create .env File' + run: | + echo "${{ env.ENV_FILE }}" > .env + - name: Build run: npm run build @@ -51,8 +61,12 @@ jobs: - name: Install Dependencies run: npm ci + - name: 'Create .env File' + run: | + echo "${{ env.ENV_FILE }}" > .env + - name: Run Unit Tests - run: npm run test.unit + run: npm run test:ci scan: name: 'Scan' @@ -73,5 +87,34 @@ jobs: - name: Install Dependencies run: npm ci + - name: 'Create .env File' + run: | + echo "${{ env.ENV_FILE }}" > .env + - name: Run Linter run: npm run lint + + validate-template: + name: 'Validate CloudFormation Template' + + runs-on: ubuntu-latest + timeout-minutes: 3 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Validate AWS CloudFormation Template + run: |- + aws cloudformation validate-template \ + --template-body file://${{ env.AWS_CFN_TEMPLATE }} diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..ca55754 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,87 @@ +name: Deploy to Development + +on: + push: + branches: + - main + tags: + - dev + +concurrency: + group: ${{ github.workflow }} + +env: + APP_NAME: ionic8-playground.leanstacks.net + AWS_CFN_STACK_NAME: ls-ui-ionic8playground-resources-dev + AWS_CFN_TEMPLATE: template.yml + AWS_ENV_CODE: dev + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_DEV }} + ENV_FILE: ${{ secrets.ENV_DEV }} + +jobs: + deploy: + name: Deploy + + runs-on: ubuntu-latest + timeout-minutes: 20 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Create Environment Configuration + run: | + echo "${{ env.ENV_FILE }}" > .env + echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env + echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env + echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env + echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env + echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env + echo "VITE_BUILD_WORKFLOW_NAME=${{ github.workflow }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_NUMBER=${{ github.run_number }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_ATTEMPT=${{ github.run_attempt }}" >> .env + + - name: Build + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy AWS CloudFormation Stack + run: |- + aws cloudformation deploy \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --template-file ${{ env.AWS_CFN_TEMPLATE }} \ + --parameter-overrides EnvironmentCode=${{ env.AWS_ENV_CODE }} \ + --tags App=${{ env.APP_NAME }} Env=${{ env.AWS_ENV_CODE }} OU=leanstacks Owner='Matthew Warman' + + - name: Get CloudFormation Stack Outputs + id: cloudformation + run: |- + APP_BUCKET_NAME=$( + aws cloudformation describe-stacks \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --query "Stacks[0].Outputs[?OutputKey=='AppBucketName'].OutputValue | [0]" + ) + echo "APP_BUCKET_NAME=$APP_BUCKET_NAME" >> "$GITHUB_OUTPUT" + + - name: Deploy to AWS S3 + run: | + aws s3 sync dist s3://${{ steps.cloudformation.outputs.APP_BUCKET_NAME }} --delete diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..381095e --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,88 @@ +name: Deploy to Production + +on: + release: + types: + - published + push: + tags: + - prod + +concurrency: + group: ${{ github.workflow }} + +env: + APP_NAME: ionic8-playground.leanstacks.net + AWS_CFN_STACK_NAME: ls-ui-ionic8playground-resources-prod + AWS_CFN_TEMPLATE: template.yml + AWS_ENV_CODE: prod + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_PROD }} + ENV_FILE: ${{ secrets.ENV_PROD }} + +jobs: + deploy: + name: Deploy + + runs-on: ubuntu-latest + timeout-minutes: 20 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Create Environment Configuration + run: | + echo "${{ env.ENV_FILE }}" > .env + echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env + echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env + echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env + echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env + echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env + echo "VITE_BUILD_WORKFLOW_NAME=${{ github.workflow }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_NUMBER=${{ github.run_number }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_ATTEMPT=${{ github.run_attempt }}" >> .env + + - name: Build + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy AWS CloudFormation Stack + run: |- + aws cloudformation deploy \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --template-file ${{ env.AWS_CFN_TEMPLATE }} \ + --parameter-overrides EnvironmentCode=${{ env.AWS_ENV_CODE }} \ + --tags App=${{ env.APP_NAME }} Env=${{ env.AWS_ENV_CODE }} OU=leanstacks Owner='Matthew Warman' + + - name: Get CloudFormation Stack Outputs + id: cloudformation + run: |- + APP_BUCKET_NAME=$( + aws cloudformation describe-stacks \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --query "Stacks[0].Outputs[?OutputKey=='AppBucketName'].OutputValue | [0]" + ) + echo "APP_BUCKET_NAME=$APP_BUCKET_NAME" >> "$GITHUB_OUTPUT" + + - name: Deploy to AWS S3 + run: | + aws s3 sync dist s3://${{ steps.cloudformation.outputs.APP_BUCKET_NAME }} --delete diff --git a/.github/workflows/deploy-qa.yml b/.github/workflows/deploy-qa.yml new file mode 100644 index 0000000..3e5fde2 --- /dev/null +++ b/.github/workflows/deploy-qa.yml @@ -0,0 +1,87 @@ +name: Deploy to QA + +on: + push: + branches: + - release/* + tags: + - qa + +concurrency: + group: ${{ github.workflow }} + +env: + APP_NAME: ionic8-playground.leanstacks.net + AWS_CFN_STACK_NAME: ls-ui-ionic8playground-resources-qa + AWS_CFN_TEMPLATE: template.yml + AWS_ENV_CODE: qa + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_QA }} + ENV_FILE: ${{ secrets.ENV_QA }} + +jobs: + deploy: + name: Deploy + + runs-on: ubuntu-latest + timeout-minutes: 20 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Create Environment Configuration + run: | + echo "${{ env.ENV_FILE }}" > .env + echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env + echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env + echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env + echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env + echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env + echo "VITE_BUILD_WORKFLOW_NAME=${{ github.workflow }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_NUMBER=${{ github.run_number }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_ATTEMPT=${{ github.run_attempt }}" >> .env + + - name: Build + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy AWS CloudFormation Stack + run: |- + aws cloudformation deploy \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --template-file ${{ env.AWS_CFN_TEMPLATE }} \ + --parameter-overrides EnvironmentCode=${{ env.AWS_ENV_CODE }} \ + --tags App=${{ env.APP_NAME }} Env=${{ env.AWS_ENV_CODE }} OU=leanstacks Owner='Matthew Warman' + + - name: Get CloudFormation Stack Outputs + id: cloudformation + run: |- + APP_BUCKET_NAME=$( + aws cloudformation describe-stacks \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --query "Stacks[0].Outputs[?OutputKey=='AppBucketName'].OutputValue | [0]" + ) + echo "APP_BUCKET_NAME=$APP_BUCKET_NAME" >> "$GITHUB_OUTPUT" + + - name: Deploy to AWS S3 + run: | + aws s3 sync dist s3://${{ steps.cloudformation.outputs.APP_BUCKET_NAME }} --delete diff --git a/.prettierrc b/.prettierrc index 41b16d2..06af2f2 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "plugins": ["prettier-plugin-tailwindcss"], + "plugins": [], "printWidth": 100, "semi": true, "singleQuote": true, diff --git a/README.md b/README.md index f97140f..208d26f 100644 --- a/README.md +++ b/README.md @@ -1 +1,269 @@ -# ionic8-playground +# Ionic 8 Playground + +A playground project for Ionic 8 with React. + +[![CI](https://github.com/mwarman/ionic8-playground/actions/workflows/ci.yml/badge.svg)](https://github.com/mwarman/ionic8-playground/actions/workflows/ci.yml) + +## About + +This project was bootstrapped with the [Ionic CLI](https://ionicframework.com/docs/cli/commands/start). + +``` +ionic start ionic8-playground blank --type=react +``` + +The technology stack includes: + +- Ionic - the foundation +- Vite - React development environment +- React - SPA framework +- React Router Dom - routing +- TanStack React Query - data manipulation and caching +- Axios - http client +- Formik - form management +- Yup - validation +- Lodash - utility functions +- DayJS - date utility functions +- Testing Library React - tests +- Vitest - tests +- MSW - API mocking +- TypeScript + +### Repository + +This repository uses [trunk-based development](https://www.atlassian.com/continuous-delivery/continuous-integration/trunk-based-development). The latest code is located on the `main` branch. The `main` branch is always ready for deployment. + +Features are developed on branches named `feature/NNNNN` which are created from the `main` branch. The feature name used in the branch contains an issue identifier or a short name, e.g. `feature/123-do-something`. + +Releases are created on branches named `release/MM.mm.pp` which are created from the `main` branch. The release name follows the [semantic versioning](https://semver.org/) specification. + +Hotfixes are created on branches named `release/MM.mm.pp` which are created from the appropriate `release/MM.mm.pp` branch. + +A pull request must be opened requesting merge from any branch back to `main`. GitHub actions perform continuous integration, CI, checks against the PR source branch. At least one code review approval is required to complete the pull request. + +See also: [Feature flags](https://www.atlassian.com/continuous-delivery/principles/feature-flags) + +### Issue Management + +This project uses [GitHub Issues](https://github.com/mwarman/ionic8-playground/issues). + +### Code Formatting + +The project includes a configuration file for the [Prettier](https://prettier.io/docs/en/configuration.html) code formatter. This allows all project contributors to share the same code formatting rules. + +Adjust the Prettier configuration as desired. + +## Installation + +### Prerequistes + +It is strongly recommended that you install Node Version Manager, [`nvm`][nvm]. Node Version Manager simplifies working on multiple projects with different versions of Node.js. + +### Clone the Repository + +Open the [repository][repo] in a browser. Follow the instructions to clone the repository to your local machine. + +### Install Node + +Open a terminal window and navigate to the project base directory. Issue the following command to install the version of Node and NPM used by the application: + +```bash +# If you already have this version of Node, simply switch to it... +nvm use + +# If you do NOT have this version of Node, install it... +nvm install +``` + +Node Version Manager inspects the `.nvmrc` file in the project base directory and uses or installs the specified version of Node and the Node Package Manager, npm. + +### Install the Dependencies + +To install the project dependencies, issue the following commands at a terminal prompt in the project base directory: + +```bash +# Switch to the project node version... +nvm use + +# Install project dependencies +npm install +``` + +### After Installation + +The installation is now complete! You may open the project in your favorite source code editor (we recommend [Visual Studio Code](https://code.visualstudio.com/)). + +We recommend the following VS Code extensions: + +- Prettier - Code formatter (required) +- ESLint - Source code analysis (strongly recommended) +- Ionic (optional) +- Indent Rainbow (optional) +- GitLens (optional) +- Dotenv Official +Vault (optional) +- GitHub Actions (optional) + +Install the _Prettier_ extension to ensure that all project participants' contributions are formatted using the same rules. The extension leverages project-specific rules found in the `.prettierrc` file in the project base directory. + +## Configuration + +The application is configured using Environment Variables. Because single-page applications are static, environment variable values are injected into the application during the build. The environment variables may be sourced from the environment or `.env` files as described in the [Vite documentation](https://vitejs.dev/guide/env-and-mode.html). + +> **NOTE:** Ionic Config provides a way to change the properties of Ionic components globally. This is different from application configuration. See the [Ionic Config](https://ionicframework.com/docs/developing/config) docs for more details. + +### `.env` files + +> **NOTE:** Because they may contain sensitive information, `.env` files are not committed to the repository. + +> **TIP:** When configuration values are modified, notify your DevOps team to modify the values in automation pipelines accordingly. + +After project installation and before running the application locally, create the following `.env` files in the project base directory. Learn more in the official [Vite guide for environment variables and modes](https://vitejs.dev/guide/env-and-mode.html). + +#### `.env.local` + +The `.env.local` configuration file provides the configuration values when the application is started on a developer's local machine. + +``` +# Provided by Pipeline (Simulated) +VITE_BUILD_DATE=1970-01-01 +VITE_BUILD_TIME=00:00:00 +VITE_BUILD_TS=1970-01-01T00:00:00+0000 +VITE_BUILD_COMMIT_SHA=local +VITE_BUILD_ENV_CODE=local +VITE_BUILD_WORKFLOW_NAME=local +VITE_BUILD_WORKFLOW_RUN_NUMBER=1 +VITE_BUILD_WORKFLOW_RUN_ATTEMPT=1 + +# API Configuration +VITE_BASE_URL_API=https://jsonplaceholder.typicode.com + +# Toasts Configuration +VITE_TOAST_AUTO_DISMISS_MILLIS=5000 +``` + +#### `.env.test.local` + +The `.env.test.local` configuration file provides configuration values used when tests are executed on a developer's local machine. + +> **NOTE:** Use the same values when running tests in a CI/CD pipeline. + +``` +# Provided by Pipeline (Simulated) +VITE_BUILD_DATE=1970-01-01 +VITE_BUILD_TIME=00:00:00 +VITE_BUILD_TS=1970-01-01T00:00:00+0000 +VITE_BUILD_COMMIT_SHA=test +VITE_BUILD_ENV_CODE=test +VITE_BUILD_WORKFLOW_NAME=test +VITE_BUILD_WORKFLOW_RUN_NUMBER=1 +VITE_BUILD_WORKFLOW_RUN_ATTEMPT=1 + +# API Configuration +VITE_BASE_URL_API=https://jsonplaceholder.typicode.com + +# Toasts Configuration +VITE_TOAST_AUTO_DISMISS_MILLIS=1500 +``` + +# Available Scripts + +Many of the scripts leverage the [Ionic CLI](https://ionicframework.com/docs/cli), the [Vite CLI](https://vitejs.dev/guide/cli.html), or the [Vitest CLI](https://vitest.dev/guide/cli.html). Read more about them in their respective official guides. + +In the project base directory, the following commands are available to run. + +### `npm run dev` + +Runs the app in the development mode. +Open [http://localhost:5173](http://localhost:5173) to view it in the browser. + +The page will reload if you make edits. + +### `npm test` + +Launches the test runner in the interactive watch mode. +See the section about [running tests](https://vitest.dev/guide/cli.html) for more information. + +### `npm run test:coverage` + +Runs the test suites once and produces a coverage report. A detailed test coverage report is created in the `./coverage` directory. + +### `npm run test:ci` + +Executes the test runner in `CI` mode and produces a coverage report. With `CI` mode enabled, the test runner executes all tests one time and prints a summary report to the console. A code coverage report is printed to the console immediately following the test summary. + +A detailed test coverage report is created in the `./coverage` directory. + +> **NOTE:** This is the command which should be utilized by CI/CD platforms. + +### `npm run test:e2e` + +Runs all end-to-end (e2e) tests using the Cypress framework. See the [Cypress CLI](https://docs.cypress.io/guides/guides/command-line) documentation for more information. + +### `npm run build` + +Builds the app for production to the `dist` folder. +It correctly bundles in production mode and optimizes the build for the best performance. + +See the official guide for more information about [building for production](https://vitejs.dev/guide/build.html) and [deploying a static site](https://vitejs.dev/guide/static-deploy.html). + +### `npm run lint` + +Runs the [ESLint][eslint] static code analysis and prints the results to the console. + +## DevOps + +### Cloud Resources + +The AWS resources for this application component are provisioned via AWS CloudFormation. The `template.yml` file is the CloudFormation template. + +The resources provisioned are: + +| Resource | Description | +| ----------------------- | ----------------------------------------------------------------------------- | +| S3 Bucket | Contains the published application. | +| S3 Bucket Policy | Provides access to the S3 Bucket from AWS CloudFront. | +| CloudFront Distribution | A CloudFront distribution to serve the SPA application. | +| CloudFront Distribution | A CloudFront distribution to serve the full-stack application (UI, API, etc). | +| Route53 RecordSet | An `A` record for the application distribution. | +| Route53 RecordSet | An `AAAA` record for the application distribution. | + +### CI/CD Pipelines + +This project uses GitHub Actions to perform DevOps automation activities such as Continuous Integration and Continous Deployment. See all project [GitHub Actions workflow runs](https://github.com/mwarman/ionic8-playground/actions). + +| Workflow | Trigger | Description | +| --------------------- | ------------------------------ | ------------------------------------------------------------------------------------ | +| CI | Pull Request for `main` branch | Builds, lints, and tests the application. Validates the AWS CloudFormation template. | +| Deploy to Development | Push to `main` branch | Deploys AWS CloudFormation stack. Builds and deploys the application. | +| Deploy to QA | Push to `release/*` branch | Deploys AWS CloudFormation stack. Builds and deploys the application. | +| Deploy to Production | Publish a Release | Deploys AWS CloudFormation stack. Builds and deploys the application. | + +## Related Information + +- [Ionic][ionic] +- [Vite][vite] +- [React][react] +- [TanStack][tanstack] +- [Axios][axios] +- [Formik][formik] +- [Yup][yup] +- [Testing Library][testing-library] +- [Vitest][vitest] +- [Cypress][cypress] +- [ESLint][eslint] +- [GitHub Actions][ghactions] + +[repo]: https://github.com/mwarman/ionic8-playground 'GitHub Repository' +[nvm]: https://github.com/nvm-sh/nvm 'Node Version Manager' +[ionic]: https://ionicframework.com/docs/react 'Ionic with React' +[vite]: https://vitejs.dev/ 'Vite' +[react]: https://react.dev/ 'React' +[axios]: https://axios-http.com/ 'Axios' +[formik]: https://formik.org/ 'Formik' +[yup]: https://github.com/jquense/yup 'Yup' +[tanstack]: https://tanstack.com/ 'TanStack' +[testing-library]: https://testing-library.com/ 'Testing Library' +[vitest]: https://vitest.dev/ 'Vitest Testing Framework' +[ghactions]: https://docs.github.com/en/actions 'GitHub Actions' +[eslint]: https://eslint.org/docs/latest/ 'ESLint' +[cypress]: https://docs.cypress.io/guides/overview/why-cypress 'Cypress Testing Framework' diff --git a/package-lock.json b/package-lock.json index f4d1c13..88bd0fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router": "5.3.4", - "react-router-dom": "5.3.4" + "react-router-dom": "5.3.4", + "yup": "1.4.0" }, "devDependencies": { "@capacitor/cli": "6.1.0", @@ -34,6 +35,7 @@ "@types/react-dom": "18.3.0", "@vitejs/plugin-legacy": "5.4.1", "@vitejs/plugin-react": "4.3.1", + "@vitest/coverage-v8": "1.6.0", "cypress": "13.12.0", "eslint": "8.57.0", "eslint-plugin-react": "7.34.3", @@ -1842,6 +1844,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "node_modules/@capacitor/app": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-6.0.0.tgz", @@ -2811,6 +2819,15 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -3462,6 +3479,33 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz", + "integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.0" + } + }, "node_modules/@vitest/expect": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", @@ -6019,6 +6063,12 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6648,6 +6698,56 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz", + "integrity": "sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", @@ -7068,6 +7168,44 @@ "@jridgewell/sourcemap-codec": "^1.4.15" } }, + "node_modules/magicast": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.4.tgz", + "integrity": "sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.24.4", + "@babel/types": "^7.24.0", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -7807,6 +7945,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -8781,6 +8924,20 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -8811,6 +8968,11 @@ "readable-stream": "3" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8863,6 +9025,11 @@ "node": ">=4" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -9791,6 +9958,28 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 0e9d3c7..1fc5a41 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test.e2e": "cypress run", - "test.unit": "vitest", + "test": "vitest", + "test:ci": "vitest run --coverage --silent", + "test:coverage": "vitest run --coverage", + "test:e2e": "cypress run", "lint": "eslint" }, "dependencies": { @@ -25,7 +27,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router": "5.3.4", - "react-router-dom": "5.3.4" + "react-router-dom": "5.3.4", + "yup": "1.4.0" }, "devDependencies": { "@capacitor/cli": "6.1.0", @@ -37,6 +40,7 @@ "@types/react-dom": "18.3.0", "@vitejs/plugin-legacy": "5.4.1", "@vitejs/plugin-react": "4.3.1", + "@vitest/coverage-v8": "1.6.0", "cypress": "13.12.0", "eslint": "8.57.0", "eslint-plugin-react": "7.34.3", diff --git a/src/App.test.tsx b/src/App.test.tsx index 1c9b3d8..f1c570b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,4 +1,4 @@ -import {describe, expect, it} from 'vitest'; +import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; import App from './App'; @@ -12,5 +12,4 @@ describe('App', () => { // ASSERT expect(screen.getByTestId('app')).toBeDefined(); }); - -}) +}); diff --git a/src/App.tsx b/src/App.tsx index fa90290..5299777 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,10 @@ import { Redirect, Route } from 'react-router-dom'; import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react'; import { IonReactRouter } from '@ionic/react-router'; -import Home from './pages/Home'; + +import ConfigContextProvider from './providers/ConfigProvider'; +import HomePage from './pages/Home/HomePage'; +import NewItemPage from './pages/Items/NewItemPage'; /* Core CSS required for Ionic components to work properly */ import '@ionic/react/css/core.css'; @@ -37,16 +40,21 @@ setupIonicReact(); const App: React.FC = () => ( - - - - - - - - - - + + + + + + + + + + + + + + + ); diff --git a/src/hooks/__tests__/useConfig.test.ts b/src/hooks/__tests__/useConfig.test.ts new file mode 100644 index 0000000..652f051 --- /dev/null +++ b/src/hooks/__tests__/useConfig.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook as renderHookWithoutWrapper } from '@testing-library/react'; + +import { renderHook, waitFor } from 'test/test-utils'; + +import { useConfig } from 'hooks/useConfig'; + +describe('useConfig', () => { + it('should return the context', async () => { + // ARRANGE + const { result } = renderHook(() => useConfig()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.VITE_BUILD_ENV_CODE).toBe('test'); + }); + + it('should throw error when not within provider', () => { + // ASSERT + expect(() => renderHookWithoutWrapper(() => useConfig())).toThrow(/hook must be used within/); + }); +}); diff --git a/src/hooks/useConfig.ts b/src/hooks/useConfig.ts new file mode 100644 index 0000000..0313d0f --- /dev/null +++ b/src/hooks/useConfig.ts @@ -0,0 +1,16 @@ +import { useContext } from 'react'; + +import { Config, ConfigContext } from '../providers/ConfigProvider'; + +/** + * The `useConfig` hook returns the current `ConfigContext` value. + * @returns {Config} The current `ConfigContext` value, `Config`. + */ +export const useConfig = (): Config => { + const context = useContext(ConfigContext); + if (!context) { + throw new Error('useConfig hook must be used within a ConfigContextProvider'); + } + + return context; +}; diff --git a/src/main.tsx b/src/main.tsx index fbff6c8..79053b5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,5 +7,5 @@ const root = createRoot(container!); root.render( - -); \ No newline at end of file + , +); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx deleted file mode 100644 index 922267d..0000000 --- a/src/pages/Home.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react'; -import ExploreContainer from '../components/ExploreContainer'; -import './Home.css'; - -const Home: React.FC = () => { - return ( - - - - Blank - - - - - - Blank - - - - - - ); -}; - -export default Home; diff --git a/src/pages/Home.css b/src/pages/Home/HomePage.css similarity index 100% rename from src/pages/Home.css rename to src/pages/Home/HomePage.css diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx new file mode 100644 index 0000000..81ca13e --- /dev/null +++ b/src/pages/Home/HomePage.tsx @@ -0,0 +1,60 @@ +import { + IonBadge, + IonCheckbox, + IonContent, + IonFab, + IonFabButton, + IonHeader, + IonIcon, + IonItem, + IonList, + IonNote, + IonPage, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import { add } from 'ionicons/icons'; +import { useHistory } from 'react-router'; + +import './HomePage.css'; + +const HomePage = (): JSX.Element => { + const history = useHistory(); + + return ( + + + + Home + + + + + + Home + + + + + + +

Create Idea

+ Run Idea by Joe +
+ + 5 Days + +
+
+ + + history.push('/new')}> + + + +
+
+ ); +}; + +export default HomePage; diff --git a/src/pages/Home/__tests__/HomePage.test.tsx b/src/pages/Home/__tests__/HomePage.test.tsx new file mode 100644 index 0000000..4b3ca51 --- /dev/null +++ b/src/pages/Home/__tests__/HomePage.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; +import HomePage from '../HomePage'; + +describe('HomePage', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-home'); + + // ASSERT + expect(screen.getByTestId('page-home')).toBeDefined(); + }); +}); diff --git a/src/pages/Items/NewItemPage.tsx b/src/pages/Items/NewItemPage.tsx new file mode 100644 index 0000000..5861329 --- /dev/null +++ b/src/pages/Items/NewItemPage.tsx @@ -0,0 +1,35 @@ +import { + IonBackButton, + IonBadge, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonPage, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import { useConfig } from '../../hooks/useConfig'; + +const NewItemPage = (): JSX.Element => { + const config = useConfig(); + + return ( + + + + + + + New Item + + + + + {config.VITE_BUILD_ENV_CODE} + + + ); +}; + +export default NewItemPage; diff --git a/src/pages/Items/__tests__/NewItemPage.test.tsx b/src/pages/Items/__tests__/NewItemPage.test.tsx new file mode 100644 index 0000000..d7a656c --- /dev/null +++ b/src/pages/Items/__tests__/NewItemPage.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; +import NewItemPage from '../NewItemPage'; + +describe('NewItemPage', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-item-new'); + + // ASSERT + expect(screen.getByTestId('page-item-new')).toBeDefined(); + }); +}); diff --git a/src/providers/ConfigProvider.tsx b/src/providers/ConfigProvider.tsx new file mode 100644 index 0000000..b9fce43 --- /dev/null +++ b/src/providers/ConfigProvider.tsx @@ -0,0 +1,75 @@ +import React, { PropsWithChildren, useEffect, useState } from 'react'; +import { ObjectSchema, ValidationError, number, object, string } from 'yup'; + +/** + * The application configuration. The `value` provided by the `ConfigContext`. + */ +export interface Config { + VITE_BASE_URL_API: string; + VITE_BUILD_DATE: string; + VITE_BUILD_TIME: string; + VITE_BUILD_TS: string; + VITE_BUILD_COMMIT_SHA: string; + VITE_BUILD_ENV_CODE: string; + VITE_BUILD_WORKFLOW_NAME: string; + VITE_BUILD_WORKFLOW_RUN_NUMBER: number; + VITE_BUILD_WORKFLOW_RUN_ATTEMPT: number; + VITE_TOAST_AUTO_DISMISS_MILLIS: number; +} + +/** + * The configuration validation schema. + * @see {@link https://github.com/jquense/yup | Yup} + */ +const configSchema: ObjectSchema = object({ + VITE_BASE_URL_API: string().url().required(), + VITE_BUILD_DATE: string().default('1970-01-01'), + VITE_BUILD_TIME: string().default('00:00:00'), + VITE_BUILD_TS: string().default('1970-01-01T00:00:00+0000'), + VITE_BUILD_COMMIT_SHA: string().default('local'), + VITE_BUILD_ENV_CODE: string().default('local'), + VITE_BUILD_WORKFLOW_NAME: string().default('local'), + VITE_BUILD_WORKFLOW_RUN_NUMBER: number().default(1), + VITE_BUILD_WORKFLOW_RUN_ATTEMPT: number().default(1), + VITE_TOAST_AUTO_DISMISS_MILLIS: number().default(5000), +}); + +/** + * The `ConfigContext` instance. + */ +export const ConfigContext = React.createContext(undefined); + +/** + * The `ConfigContextProvider` React component creates, maintains, and provides + * access to the `ConfigContext` value. + * Validates the React application configuration values from `import.meta.env`. + * Throws an `Error` when the configuration is invalid, preventing application + * startup. + * @param {PropsWithChildren} props - Component properties, `PropsWithChildren`. + * @returns {JSX.Element} JSX + */ +const ConfigContextProvider = ({ children }: PropsWithChildren) => { + const [isReady, setIsReady] = useState(false); + const [config, setConfig] = useState(); + + useEffect(() => { + try { + const validatedConfig = configSchema.validateSync(import.meta.env, { + abortEarly: false, + stripUnknown: true, + }); + setConfig(validatedConfig); + setIsReady(true); + } catch (err) { + if (err instanceof ValidationError) throw new Error(`${err}::${err.errors}`); + if (err instanceof Error) throw new Error(`Configuration error: ${err.message}`); + throw err; + } + }, []); + + return ( + {isReady && <>{children}} + ); +}; + +export default ConfigContextProvider; diff --git a/src/providers/__tests__/ConfigProvider.test.tsx b/src/providers/__tests__/ConfigProvider.test.tsx new file mode 100644 index 0000000..0c9bdfe --- /dev/null +++ b/src/providers/__tests__/ConfigProvider.test.tsx @@ -0,0 +1,46 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import ConfigContextProvider from 'providers/ConfigProvider'; + +describe('ConfigProvider', () => { + it('should render successfully', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('provider-config'); + + // ASSERT + expect(screen.getByTestId('provider-config')).toBeDefined(); + }); +}); + +describe.skip('ConfigProvider error', () => { + const originalEnv = process.env; + + beforeAll(() => { + process.env = { NODE_ENV: 'test', PUBLIC_URL: 'localhost' }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should throw configuration validation error', () => { + // ARRANGE + function renderContextProvider() { + render( + +
+
, + ); + } + + // ASSERT + expect(renderContextProvider).toThrow(/is a required field/); + }); +}); diff --git a/src/setupTests.ts b/src/setupTests.ts index 1a707f1..41e9eb7 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -5,10 +5,12 @@ import '@testing-library/jest-dom/vitest'; // Mock matchmedia -window.matchMedia = window.matchMedia || function() { - return { +window.matchMedia = + window.matchMedia || + function () { + return { matches: false, - addListener: function() {}, - removeListener: function() {} + addListener: function () {}, + removeListener: function () {}, + }; }; -}; diff --git a/src/test/test-utils.tsx b/src/test/test-utils.tsx new file mode 100644 index 0000000..045d9e9 --- /dev/null +++ b/src/test/test-utils.tsx @@ -0,0 +1,39 @@ +import { + queries, + Queries, + render, + renderHook, + RenderHookOptions, + RenderOptions, +} from '@testing-library/react'; + +import WithAllProviders from './wrappers/WithAllProviders'; + +const customRender = (ui: React.ReactElement, options?: RenderOptions, { route = '/' } = {}) => { + window.history.pushState({}, 'Test page', route); + + return render(ui, { wrapper: WithAllProviders, ...options }); +}; + +function customRenderHook< + Result, + Props, + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +>( + render: (initialProps: Props) => Result, + options?: RenderHookOptions, +) { + return renderHook(render, { wrapper: WithAllProviders, ...options }); +} + +// re-export @testing-library/react +// eslint-disable-next-line +export * from '@testing-library/react'; + +// override the render function +export { customRender as render }; + +// override the renderHook function +export { customRenderHook as renderHook }; diff --git a/src/test/wrappers/WithAllProviders.tsx b/src/test/wrappers/WithAllProviders.tsx new file mode 100644 index 0000000..891a7a0 --- /dev/null +++ b/src/test/wrappers/WithAllProviders.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router'; + +import ConfigContextProvider from 'providers/ConfigProvider'; + +const WithAllProviders = ({ children }: PropsWithChildren): JSX.Element => { + return ( + + {children} + + ); +}; + +export default WithAllProviders; diff --git a/template.yml b/template.yml new file mode 100644 index 0000000..146eda5 --- /dev/null +++ b/template.yml @@ -0,0 +1,226 @@ +Description: Ionic 8 Playground component resources + +Parameters: + EnvironmentCode: + Type: String + Description: Select an Environment + AllowedValues: + - dev + - qa + - prod + Default: dev + ConstraintDescription: Must select a valid environment + +Mappings: + EnvironmentAttributeMap: + dev: + CertificateArn: arn:aws:acm:us-east-1:988218269141:certificate/3d110b0f-8b3d-4ddc-bbd8-fab08ae6f038 + CloudFrontOAID: E2U9SKLVDD8TPN + HostedZone: dev.leanstacks.net + qa: + CertificateArn: arn:aws:acm:us-east-1:339939222800:certificate/5cd1bce7-1323-4625-a49e-5e72d1cff7ef + CloudFrontOAID: E322H9D7WOKWXW + HostedZone: qa.leanstacks.net + prod: + CertificateArn: arn:aws:acm:us-east-1:854599584783:certificate/fc25a13b-0c9f-4c79-a20f-a13f5d2245b3 + CloudFrontOAID: EVMQ2O0M1MS7S + HostedZone: leanstacks.net + +Resources: + ## + # S3 Bucket for the Ionic Web App + ## + BucketApp: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub + - 'ionic8-ui-app.${HostedZone}-${AWS::Region}-${AWS::AccountId}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + + ## + # Bucket Policy allows access from AWS CloudFront + ## + BucketPolicyApp: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref BucketApp + PolicyDocument: + Statement: + - Action: + - s3:GetObject + Effect: Allow + Resource: !Join + - '' + - - 'arn:aws:s3:::' + - !Ref BucketApp + - '/*' + Principal: + AWS: !Sub + - 'arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOAID}' + - CloudFrontOAID: + !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, CloudFrontOAID] + + ## + # CloudFront Distribution for the Ionic Web App - SPA errors and behaviors + ## + DistributionUi: + Type: AWS::CloudFront::Distribution + Properties: + DistributionConfig: + Comment: !Sub 'Ionic 8 Playground UI SPA (${EnvironmentCode})' + CustomErrorResponses: + - ErrorCode: 404 + ResponsePagePath: '/index.html' + ResponseCode: 200 + - ErrorCode: 403 + ResponsePagePath: '/index.html' + ResponseCode: 200 + DefaultCacheBehavior: + AllowedMethods: + - GET + - HEAD + - OPTIONS + DefaultTTL: 60 + ForwardedValues: + Cookies: + Forward: none + QueryString: false + TargetOriginId: S3-APP + ViewerProtocolPolicy: redirect-to-https + DefaultRootObject: index.html + Enabled: true + HttpVersion: http2 + Origins: + - DomainName: !GetAtt BucketApp.DomainName + Id: S3-APP + S3OriginConfig: + OriginAccessIdentity: !Sub + - 'origin-access-identity/cloudfront/${CloudFrontOAID}' + - CloudFrontOAID: + !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, CloudFrontOAID] + PriceClass: PriceClass_100 + + ## + # CloudFront Distribution for complete, full-stack APP - routing for API and UI + ## + DistributionApp: + Type: AWS::CloudFront::Distribution + Properties: + DistributionConfig: + Comment: !Sub 'Ionic 8 Playground UI App (${EnvironmentCode})' + Aliases: + - !Sub + - 'ionic8.${HostedZone}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + CacheBehaviors: + - AllowedMethods: + - DELETE + - GET + - HEAD + - OPTIONS + - PATCH + - POST + - PUT + DefaultTTL: 0 + ForwardedValues: + Cookies: + Forward: none + Headers: + - Accept + - Authorization + - Content-Type + - X-Requested-With + QueryString: true + MaxTTL: 0 + MinTTL: 0 + PathPattern: /api* + TargetOriginId: CUSTOM-API + ViewerProtocolPolicy: redirect-to-https + DefaultCacheBehavior: + AllowedMethods: + - GET + - HEAD + - OPTIONS + DefaultTTL: 60 + ForwardedValues: + Cookies: + Forward: none + QueryString: false + TargetOriginId: CUSTOM-UI + ViewerProtocolPolicy: redirect-to-https + DefaultRootObject: index.html + Enabled: true + HttpVersion: http2 + Origins: + - CustomOriginConfig: + HTTPPort: 80 + HTTPSPort: 443 + OriginProtocolPolicy: https-only + OriginSSLProtocols: + - SSLv3 + - TLSv1 + - TLSv1.1 + - TLSv1.2 + DomainName: !GetAtt DistributionUi.DomainName + Id: CUSTOM-UI + - CustomOriginConfig: + HTTPPort: 80 + HTTPSPort: 443 + OriginProtocolPolicy: https-only + OriginSSLProtocols: + - SSLv3 + - TLSv1 + - TLSv1.1 + - TLSv1.2 + DomainName: !Sub + - 'api.${HostedZone}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Id: CUSTOM-API + PriceClass: PriceClass_100 + ViewerCertificate: + AcmCertificateArn: + !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, CertificateArn] + SslSupportMethod: sni-only + + ## + # Route53 DNS for the 'App' CloudFront Distribution + ## + RecordSetAppA: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneName: !Sub + - '${HostedZone}.' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Name: !Sub + - 'ionic8.${HostedZone}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Type: A + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt DistributionApp.DomainName + + ## + # Route53 DNS for the 'App' CloudFront Distribution + ## + RecordSetAppAAAA: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneName: !Sub + - '${HostedZone}.' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Name: !Sub + - 'ionic8.${HostedZone}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Type: AAAA + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt DistributionApp.DomainName + +Outputs: + AppBucketName: + Description: The application S3 bucket name + Value: !Ref BucketApp + + DomainName: + Description: The application domain name + Value: !Ref RecordSetAppA diff --git a/tsconfig.json b/tsconfig.json index 3d0a51a..60a1865 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,20 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + + /* Absolute imports */ + "paths": { + "__fixtures__/*": ["./src/__fixtures__/*"], + "api/*": ["./src/api/*"], + "assets/*": ["./src/assets/*"], + "components/*": ["./src/components/*"], + "hooks/*": ["./src/hooks/*"], + "pages/*": ["./src/pages/*"], + "providers/*": ["./src/providers/*"], + "test/*": ["./src/test/*"], + "utils/*": ["./src/utils/*"] + } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/vite.config.ts b/vite.config.ts index 89f5ecd..174df98 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,18 +1,41 @@ /// -import legacy from '@vitejs/plugin-legacy' -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import legacy from '@vitejs/plugin-legacy'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import { coverageConfigDefaults } from 'vitest/config'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [ - react(), - legacy() - ], + plugins: [react(), legacy()], + resolve: { + alias: { + __fixtures__: '/src/__fixtures__', + api: '/src/api', + assets: '/src/assets', + components: '/src/components', + hooks: '/src/hooks', + pages: '/src/pages', + providers: '/src/providers', + test: '/src/test', + utils: '/src/utils', + }, + }, test: { + coverage: { + provider: 'v8', + exclude: [ + '**/__fixtures__/**', + '**/__mocks__/**', + 'src/main.tsx', + 'src/test', + 'capacitor.config.ts', + ...coverageConfigDefaults.exclude, + ], + }, globals: true, environment: 'jsdom', + mockReset: true, setupFiles: './src/setupTests.ts', - } -}) + }, +});