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',
- }
-})
+ },
+});