├── .azdo └── pipelines │ └── azure-dev.yml ├── .devcontainer └── devcontainer.json ├── .gitattributes ├── .github └── workflows │ └── azure-dev.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── LICENSE ├── NOTICE.txt ├── README.md ├── assets ├── resources-with-apim.png ├── resources.png ├── urls.png └── web.png ├── azure.yaml ├── infra ├── abbreviations.json ├── app │ ├── api-appservice-avm.bicep │ ├── db-avm.bicep │ └── web-appservice-avm.bicep ├── main.bicep └── main.parameters.json ├── openapi.yaml ├── src ├── api │ ├── .dockerignore │ ├── .eslintrc.json │ ├── .gitattributes │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── babel.config.js │ ├── config │ │ ├── custom-environment-variables.json │ │ └── default.json │ ├── openapi.yaml │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.ts │ │ ├── config │ │ │ ├── appConfig.ts │ │ │ ├── applicationInsightsTransport.ts │ │ │ ├── index.ts │ │ │ └── observability.ts │ │ ├── index.ts │ │ ├── models │ │ │ ├── mongoose.ts │ │ │ ├── todoItem.ts │ │ │ └── todoList.ts │ │ └── routes │ │ │ ├── common.ts │ │ │ ├── items.ts │ │ │ ├── lists.ts │ │ │ └── routes.spec.ts │ └── tsconfig.json └── web │ ├── .dockerignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── index.html │ ├── nginx │ └── nginx.conf │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── favicon.ico │ └── manifest.json │ ├── src │ ├── @types │ │ └── window.d.ts │ ├── App.css │ ├── App.tsx │ ├── actions │ │ ├── actionCreators.ts │ │ ├── common.ts │ │ ├── itemActions.ts │ │ └── listActions.ts │ ├── components │ │ ├── telemetry.tsx │ │ ├── telemetryContext.ts │ │ ├── telemetryWithAppInsights.tsx │ │ ├── todoContext.ts │ │ ├── todoItemDetailPane.tsx │ │ ├── todoItemListPane.tsx │ │ └── todoListMenu.tsx │ ├── config │ │ └── index.ts │ ├── index.css │ ├── index.tsx │ ├── layout │ │ ├── header.tsx │ │ ├── layout.tsx │ │ └── sidebar.tsx │ ├── models │ │ ├── applicationState.ts │ │ ├── index.ts │ │ ├── todoItem.ts │ │ └── todoList.ts │ ├── pages │ │ └── homePage.tsx │ ├── react-app-env.d.ts │ ├── reducers │ │ ├── index.ts │ │ ├── listsReducer.ts │ │ ├── selectedItemReducer.ts │ │ └── selectedListReducer.ts │ ├── reportWebVitals.ts │ ├── services │ │ ├── itemService.ts │ │ ├── listService.ts │ │ ├── restService.ts │ │ └── telemetryService.ts │ ├── setupTests.ts │ └── ux │ │ ├── styles.ts │ │ └── theme.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── web.config └── tests ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── playwright.config.ts └── todo.spec.ts /.azdo/pipelines/azure-dev.yml: -------------------------------------------------------------------------------- 1 | # Run when commits are pushed to mainline branch (main or master) 2 | # Set this to the mainline branch you are using 3 | trigger: 4 | - main 5 | - master 6 | 7 | # Azure Pipelines workflow to deploy to Azure using azd 8 | # To configure required secrets and service connection for connecting to Azure, simply run `azd pipeline config --provider azdo` 9 | # Task "Install azd" needs to install setup-azd extension for azdo - https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd 10 | # See below for alternative task to install azd if you can't install above task in your organization 11 | 12 | pool: 13 | vmImage: ubuntu-latest 14 | 15 | steps: 16 | - task: setup-azd@1 17 | displayName: Install azd 18 | 19 | # If you can't install above task in your organization, you can comment it and uncomment below task to install azd 20 | # - task: Bash@3 21 | # displayName: Install azd 22 | # inputs: 23 | # targetType: 'inline' 24 | # script: | 25 | # curl -fsSL https://aka.ms/install-azd.sh | bash 26 | 27 | # azd delegate auth to az to use service connection with AzureCLI@2 28 | - pwsh: | 29 | azd config set auth.useAzCliAuth "true" 30 | displayName: Configure AZD to Use AZ CLI Authentication. 31 | 32 | - task: AzureCLI@2 33 | displayName: Provision Infrastructure 34 | inputs: 35 | azureSubscription: azconnection 36 | scriptType: bash 37 | scriptLocation: inlineScript 38 | inlineScript: | 39 | azd provision --no-prompt 40 | env: 41 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 42 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 43 | AZURE_LOCATION: $(AZURE_LOCATION) 44 | AZD_INITIAL_ENVIRONMENT_CONFIG: $(secrets.AZD_INITIAL_ENVIRONMENT_CONFIG) 45 | 46 | - task: AzureCLI@2 47 | displayName: Deploy Application 48 | inputs: 49 | azureSubscription: azconnection 50 | scriptType: bash 51 | scriptLocation: inlineScript 52 | inlineScript: | 53 | azd deploy --no-prompt 54 | env: 55 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 56 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 57 | AZURE_LOCATION: $(AZURE_LOCATION) 58 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure Developer CLI", 3 | "image": "mcr.microsoft.com/devcontainers/javascript-node:20-bullseye", 4 | "features": { 5 | "ghcr.io/devcontainers/features/docker-in-docker:2": { 6 | }, 7 | "ghcr.io/azure/azure-dev/azd:latest": {} 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "extensions": [ 12 | "dbaeumer.vscode-eslint", 13 | "esbenp.prettier-vscode", 14 | "GitHub.vscode-github-actions", 15 | "ms-azuretools.azure-dev", 16 | "ms-azuretools.vscode-azurefunctions", 17 | "ms-azuretools.vscode-bicep", 18 | "ms-azuretools.vscode-docker", 19 | "ms-vscode.js-debug", 20 | "ms-vscode.vscode-node-azure-pack" 21 | ] 22 | } 23 | }, 24 | "forwardPorts": [ 25 | 3000, 26 | 3100 27 | ], 28 | "postCreateCommand": "", 29 | "remoteUser": "node", 30 | "hostRequirements": { 31 | "memory": "8gb" 32 | } 33 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /.github/workflows/azure-dev.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | # Run when commits are pushed to mainline branch (main or master) 5 | # Set this to the mainline branch you are using 6 | branches: 7 | - main 8 | - master 9 | 10 | # GitHub Actions workflow to deploy to Azure using azd 11 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config` 12 | 13 | # Set up permissions for deploying with secretless Azure federated credentials 14 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication 15 | permissions: 16 | id-token: write 17 | contents: read 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | env: 23 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 24 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 25 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 26 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Install azd 32 | uses: Azure/setup-azd@v2 33 | 34 | - name: Install Nodejs 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 18 38 | 39 | - name: Log in with Azure (Federated Credentials) 40 | if: ${{ env.AZURE_CLIENT_ID != '' }} 41 | run: | 42 | azd auth login ` 43 | --client-id "$Env:AZURE_CLIENT_ID" ` 44 | --federated-credential-provider "github" ` 45 | --tenant-id "$Env:AZURE_TENANT_ID" 46 | shell: pwsh 47 | 48 | - name: Log in with Azure (Client Credentials) 49 | if: ${{ env.AZURE_CREDENTIALS != '' }} 50 | run: | 51 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 52 | Write-Host "::add-mask::$($info.clientSecret)" 53 | 54 | azd auth login ` 55 | --client-id "$($info.clientId)" ` 56 | --client-secret "$($info.clientSecret)" ` 57 | --tenant-id "$($info.tenantId)" 58 | shell: pwsh 59 | env: 60 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 61 | 62 | - name: Provision Infrastructure 63 | run: azd provision --no-prompt 64 | env: 65 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 66 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 67 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 68 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 69 | 70 | - name: Deploy Application 71 | run: azd deploy --no-prompt 72 | env: 73 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 74 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 75 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .azure -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.azure-dev" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Web", 9 | "request": "launch", 10 | "type": "msedge", 11 | "webRoot": "${workspaceFolder}/src/web/src", 12 | "url": "http://localhost:3000", 13 | "sourceMapPathOverrides": { 14 | "webpack:///src/*": "${webRoot}/*" 15 | }, 16 | }, 17 | 18 | { 19 | "name": "Debug API", 20 | "request": "launch", 21 | "runtimeArgs": [ 22 | "run", 23 | "start" 24 | ], 25 | "runtimeExecutable": "npm", 26 | "skipFiles": [ 27 | "/**" 28 | ], 29 | "type": "node", 30 | "cwd": "${workspaceFolder}/src/api", 31 | "envFile": "${input:dotEnvFilePath}", 32 | "env": { 33 | "NODE_ENV": "development" 34 | }, 35 | "preLaunchTask": "Restore API", 36 | "outputCapture": "std" 37 | }, 38 | ], 39 | 40 | "inputs": [ 41 | { 42 | "id": "dotEnvFilePath", 43 | "type": "command", 44 | "command": "azure-dev.commands.getDotEnvFilePath" 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Start Web", 6 | "type": "dotenv", 7 | "targetTasks": [ 8 | "Restore Web", 9 | "Web npm start" 10 | ], 11 | "file": "${input:dotEnvFilePath}" 12 | }, 13 | { 14 | "label": "Restore Web", 15 | "type": "shell", 16 | "command": "azd restore web", 17 | "presentation": { 18 | "reveal": "silent" 19 | }, 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Web npm start", 24 | "detail": "Helper task--use 'Start Web' task to ensure environment is set up correctly", 25 | "type": "shell", 26 | "command": "npx -y cross-env VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" npm run dev", 27 | "options": { 28 | "cwd": "${workspaceFolder}/src/web/", 29 | "env": { 30 | "VITE_API_BASE_URL": "http://localhost:3100", 31 | "BROWSER": "none" 32 | } 33 | }, 34 | "presentation": { 35 | "panel": "dedicated", 36 | }, 37 | "problemMatcher": [] 38 | }, 39 | 40 | { 41 | "label": "Start API", 42 | "type": "dotenv", 43 | "targetTasks": [ 44 | "Restore API", 45 | "API npm start" 46 | ], 47 | "file": "${input:dotEnvFilePath}" 48 | }, 49 | { 50 | "label": "Restore API", 51 | "type": "shell", 52 | "command": "azd restore api", 53 | "presentation": { 54 | "reveal": "silent" 55 | }, 56 | "problemMatcher": [] 57 | }, 58 | { 59 | "label": "API npm start", 60 | "detail": "Helper task--use 'Start API' task to ensure environment is set up correctly", 61 | "type": "shell", 62 | "command": "npm run start", 63 | "options": { 64 | "cwd": "${workspaceFolder}/src/api/", 65 | "env": { 66 | "NODE_ENV": "development" 67 | } 68 | }, 69 | "presentation": { 70 | "panel": "dedicated", 71 | }, 72 | "problemMatcher": [] 73 | }, 74 | 75 | { 76 | "label": "Start API and Web", 77 | "dependsOn":[ 78 | "Start API", 79 | "Start Web" 80 | ], 81 | "problemMatcher": [] 82 | } 83 | ], 84 | 85 | "inputs": [ 86 | { 87 | "id": "dotEnvFilePath", 88 | "type": "command", 89 | "command": "azure-dev.commands.getDotEnvFilePath" 90 | } 91 | ] 92 | } 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2022 (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | languages: 4 | - azdeveloper 5 | - nodejs 6 | - bicep 7 | - typescript 8 | - html 9 | products: 10 | - azure 11 | - azure-cosmos-db 12 | - azure-app-service 13 | - azure-monitor 14 | - azure-pipelines 15 | urlFragment: todo-nodejs-mongo 16 | name: React Web App with Node.js API and MongoDB on Azure 17 | description: A complete ToDo app on Azure App Service with Node.js API and Azure Cosmos API for MongoDB for storage. Uses Azure Developer CLI (azd) to build, deploy, and monitor 18 | --- 19 | 20 | 21 | # React Web App with Node.js API and MongoDB on Azure 22 | 23 | [![Open in GitHub Codespaces](https://img.shields.io/static/v1?style=for-the-badge&label=GitHub+Codespaces&message=Open&color=brightgreen&logo=github)](https://codespaces.new/azure-samples/todo-nodejs-mongo) 24 | [![Open in Dev Container](https://img.shields.io/static/v1?style=for-the-badge&label=Dev+Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/azure-samples/todo-nodejs-mongo) 25 | 26 | A blueprint for getting a React web app with a Node.js API and a MongoDB database running on Azure. The blueprint includes sample application code (a ToDo web app) which can be removed and replaced with your own application code. Add your own source code and leverage the Infrastructure as Code assets (written in Bicep) to get up and running quickly. 27 | 28 | Let's jump in and get this up and running in Azure. When you are finished, you will have a fully functional web app deployed to the cloud. In later steps, you'll see how to setup a pipeline and monitor the application. 29 | 30 | !["Screenshot of deployed ToDo app"](assets/web.png) 31 | 32 | Screenshot of the deployed ToDo app 33 | 34 | ### Prerequisites 35 | 36 | The following prerequisites are required to use this application. Please ensure that you have them all installed locally. 37 | 38 | - [Azure Developer CLI](https://aka.ms/azd-install) 39 | - [Node.js with npm (18.17.1+)](https://nodejs.org/) - for API backend and Web frontend 40 | 41 | ### Quickstart 42 | To learn how to get started with any template, follow the steps in [this quickstart](https://learn.microsoft.com/azure/developer/azure-developer-cli/get-started?tabs=localinstall&pivots=programming-language-nodejs) with this template(`Azure-Samples/todo-nodejs-mongo`). 43 | 44 | This quickstart will show you how to authenticate on Azure, initialize using a template, provision infrastructure and deploy code on Azure via the following commands: 45 | 46 | ```bash 47 | # Log in to azd. Only required once per-install. 48 | azd auth login 49 | 50 | # First-time project setup. Initialize a project in the current directory, using this template. 51 | azd init --template Azure-Samples/todo-nodejs-mongo 52 | 53 | # Provision and deploy to Azure 54 | azd up 55 | ``` 56 | 57 | ### Application Architecture 58 | 59 | This application utilizes the following Azure resources: 60 | 61 | - [**Azure App Services**](https://docs.microsoft.com/azure/app-service/) to host the Web frontend and API backend 62 | - [**Azure Cosmos DB API for MongoDB**](https://docs.microsoft.com/azure/cosmos-db/mongodb/mongodb-introduction) for storage 63 | - [**Azure Monitor**](https://docs.microsoft.com/azure/azure-monitor/) for monitoring and logging 64 | - [**Azure Key Vault**](https://docs.microsoft.com/azure/key-vault/) for securing secrets 65 | 66 | Here's a high level architecture diagram that illustrates these components. Notice that these are all contained within a single [resource group](https://docs.microsoft.com/azure/azure-resource-manager/management/manage-resource-groups-portal), that will be created for you when you create the resources. 67 | 68 | !["Application architecture diagram"](assets/resources.png) 69 | 70 | > This template provisions resources to an Azure subscription that you will select upon provisioning them. Please refer to the [Pricing calculator for Microsoft Azure](https://azure.microsoft.com/pricing/calculator/) and, if needed, update the included Azure resource definitions found in `infra/main.bicep` to suit your needs. 71 | 72 | ### Application Code 73 | 74 | This template is structured to follow the [Azure Developer CLI](https://aka.ms/azure-dev/overview). You can learn more about `azd` architecture in [the official documentation](https://learn.microsoft.com/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-create#understand-the-azd-architecture). 75 | 76 | ### Next Steps 77 | 78 | At this point, you have a complete application deployed on Azure. But there is much more that the Azure Developer CLI can do. These next steps will introduce you to additional commands that will make creating applications on Azure much easier. Using the Azure Developer CLI, you can setup your pipelines, monitor your application, test and debug locally. 79 | 80 | > Note: Needs to manually install [setup-azd extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd) for Azure DevOps (azdo). 81 | 82 | - [`azd pipeline config`](https://learn.microsoft.com/azure/developer/azure-developer-cli/configure-devops-pipeline?tabs=GitHub) - to configure a CI/CD pipeline (using GitHub Actions or Azure DevOps) to deploy your application whenever code is pushed to the main branch. 83 | 84 | - [`azd monitor`](https://learn.microsoft.com/azure/developer/azure-developer-cli/monitor-your-app) - to monitor the application and quickly navigate to the various Application Insights dashboards (e.g. overview, live metrics, logs) 85 | 86 | - [Run and Debug Locally](https://learn.microsoft.com/azure/developer/azure-developer-cli/debug?pivots=ide-vs-code) - using Visual Studio Code and the Azure Developer CLI extension 87 | 88 | - [`azd down`](https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-down) - to delete all the Azure resources created with this template 89 | 90 | - [Enable optional features, like APIM](./OPTIONAL_FEATURES.md) - for enhanced backend API protection and observability 91 | 92 | ### Additional `azd` commands 93 | 94 | The Azure Developer CLI includes many other commands to help with your Azure development experience. You can view these commands at the terminal by running `azd help`. You can also view the full list of commands on our [Azure Developer CLI command](https://aka.ms/azure-dev/ref) page. 95 | 96 | ## Security 97 | 98 | ### Roles 99 | 100 | This template creates a [managed identity](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview) for your app inside your Azure Active Directory tenant, and it is used to authenticate your app with Azure and other services that support Azure AD authentication like Key Vault via access policies. You will see principalId referenced in the infrastructure as code files, that refers to the id of the currently logged in Azure Developer CLI user, which will be granted access policies and permissions to run the application locally. To view your managed identity in the Azure Portal, follow these [steps](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/how-to-view-managed-identity-service-principal-portal). 101 | 102 | ### Key Vault 103 | 104 | This template uses [Azure Key Vault](https://docs.microsoft.com/azure/key-vault/general/overview) to securely store your Cosmos DB connection string for the provisioned Cosmos DB account. Key Vault is a cloud service for securely storing and accessing secrets (API keys, passwords, certificates, cryptographic keys) and makes it simple to give other Azure services access to them. As you continue developing your solution, you may add as many secrets to your Key Vault as you require. 105 | 106 | ## Reporting Issues and Feedback 107 | 108 | If you have any feature requests, issues, or areas for improvement, please [file an issue](https://aka.ms/azure-dev/issues). To keep up-to-date, ask questions, or share suggestions, join our [GitHub Discussions](https://aka.ms/azure-dev/discussions). You may also contact us via AzDevTeam@microsoft.com. 109 | -------------------------------------------------------------------------------- /assets/resources-with-apim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo/1a5662ba27396ff4bac0300f101a3bd836233177/assets/resources-with-apim.png -------------------------------------------------------------------------------- /assets/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo/1a5662ba27396ff4bac0300f101a3bd836233177/assets/resources.png -------------------------------------------------------------------------------- /assets/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo/1a5662ba27396ff4bac0300f101a3bd836233177/assets/urls.png -------------------------------------------------------------------------------- /assets/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo/1a5662ba27396ff4bac0300f101a3bd836233177/assets/web.png -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: todo-nodejs-mongo 4 | metadata: 5 | template: todo-nodejs-mongo@0.0.1-beta 6 | workflows: 7 | up: 8 | steps: 9 | - azd: provision 10 | - azd: deploy --all 11 | services: 12 | web: 13 | project: ./src/web 14 | dist: dist 15 | language: js 16 | host: appservice 17 | hooks: 18 | # Creates a temporary `.env.local` file for the build command. Vite will automatically use it during build. 19 | # The expected/required values are mapped to the infrastructure outputs. 20 | # .env.local is ignored by git, so it will not be committed if, for any reason, if deployment fails. 21 | # see: https://vitejs.dev/guide/env-and-mode 22 | # Note: Notice that dotenv must be a project dependency for this to work. See package.json. 23 | prepackage: 24 | windows: 25 | shell: pwsh 26 | run: 'echo "VITE_API_BASE_URL=""$env:API_BASE_URL""" > .env.local ; echo "VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=""$env:APPLICATIONINSIGHTS_CONNECTION_STRING""" >> .env.local' 27 | posix: 28 | shell: sh 29 | run: 'echo VITE_API_BASE_URL=\"$API_BASE_URL\" > .env.local && echo VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" >> .env.local' 30 | postdeploy: 31 | windows: 32 | shell: pwsh 33 | run: 'rm .env.local' 34 | posix: 35 | shell: sh 36 | run: 'rm .env.local' 37 | api: 38 | project: ./src/api 39 | language: js 40 | host: appservice 41 | -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- 1 | { 2 | "analysisServicesServers": "as", 3 | "apiManagementService": "apim-", 4 | "appConfigurationStores": "appcs-", 5 | "appManagedEnvironments": "cae-", 6 | "appContainerApps": "ca-", 7 | "authorizationPolicyDefinitions": "policy-", 8 | "automationAutomationAccounts": "aa-", 9 | "blueprintBlueprints": "bp-", 10 | "blueprintBlueprintsArtifacts": "bpa-", 11 | "cacheRedis": "redis-", 12 | "cdnProfiles": "cdnp-", 13 | "cdnProfilesEndpoints": "cdne-", 14 | "cognitiveServicesAccounts": "cog-", 15 | "cognitiveServicesFormRecognizer": "cog-fr-", 16 | "cognitiveServicesTextAnalytics": "cog-ta-", 17 | "cognitiveServicesSpeech": "cog-sp-", 18 | "computeAvailabilitySets": "avail-", 19 | "computeCloudServices": "cld-", 20 | "computeDiskEncryptionSets": "des", 21 | "computeDisks": "disk", 22 | "computeDisksOs": "osdisk", 23 | "computeGalleries": "gal", 24 | "computeSnapshots": "snap-", 25 | "computeVirtualMachines": "vm", 26 | "computeVirtualMachineScaleSets": "vmss-", 27 | "containerInstanceContainerGroups": "ci", 28 | "containerRegistryRegistries": "cr", 29 | "containerServiceManagedClusters": "aks-", 30 | "databricksWorkspaces": "dbw-", 31 | "dataFactoryFactories": "adf-", 32 | "dataLakeAnalyticsAccounts": "dla", 33 | "dataLakeStoreAccounts": "dls", 34 | "dataMigrationServices": "dms-", 35 | "dBforMySQLServers": "mysql-", 36 | "dBforPostgreSQLServers": "psql-", 37 | "devicesIotHubs": "iot-", 38 | "devicesProvisioningServices": "provs-", 39 | "devicesProvisioningServicesCertificates": "pcert-", 40 | "documentDBDatabaseAccounts": "cosmos-", 41 | "eventGridDomains": "evgd-", 42 | "eventGridDomainsTopics": "evgt-", 43 | "eventGridEventSubscriptions": "evgs-", 44 | "eventHubNamespaces": "evhns-", 45 | "eventHubNamespacesEventHubs": "evh-", 46 | "hdInsightClustersHadoop": "hadoop-", 47 | "hdInsightClustersHbase": "hbase-", 48 | "hdInsightClustersKafka": "kafka-", 49 | "hdInsightClustersMl": "mls-", 50 | "hdInsightClustersSpark": "spark-", 51 | "hdInsightClustersStorm": "storm-", 52 | "hybridComputeMachines": "arcs-", 53 | "insightsActionGroups": "ag-", 54 | "insightsComponents": "appi-", 55 | "keyVaultVaults": "kv-", 56 | "kubernetesConnectedClusters": "arck", 57 | "kustoClusters": "dec", 58 | "kustoClustersDatabases": "dedb", 59 | "loadTesting": "lt-", 60 | "logicIntegrationAccounts": "ia-", 61 | "logicWorkflows": "logic-", 62 | "machineLearningServicesWorkspaces": "mlw-", 63 | "managedIdentityUserAssignedIdentities": "id-", 64 | "managementManagementGroups": "mg-", 65 | "migrateAssessmentProjects": "migr-", 66 | "networkApplicationGateways": "agw-", 67 | "networkApplicationSecurityGroups": "asg-", 68 | "networkAzureFirewalls": "afw-", 69 | "networkBastionHosts": "bas-", 70 | "networkConnections": "con-", 71 | "networkDnsZones": "dnsz-", 72 | "networkExpressRouteCircuits": "erc-", 73 | "networkFirewallPolicies": "afwp-", 74 | "networkFirewallPoliciesWebApplication": "waf", 75 | "networkFirewallPoliciesRuleGroups": "wafrg", 76 | "networkFrontDoors": "fd-", 77 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", 78 | "networkLoadBalancersExternal": "lbe-", 79 | "networkLoadBalancersInternal": "lbi-", 80 | "networkLoadBalancersInboundNatRules": "rule-", 81 | "networkLocalNetworkGateways": "lgw-", 82 | "networkNatGateways": "ng-", 83 | "networkNetworkInterfaces": "nic-", 84 | "networkNetworkSecurityGroups": "nsg-", 85 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", 86 | "networkNetworkWatchers": "nw-", 87 | "networkPrivateDnsZones": "pdnsz-", 88 | "networkPrivateLinkServices": "pl-", 89 | "networkPublicIPAddresses": "pip-", 90 | "networkPublicIPPrefixes": "ippre-", 91 | "networkRouteFilters": "rf-", 92 | "networkRouteTables": "rt-", 93 | "networkRouteTablesRoutes": "udr-", 94 | "networkTrafficManagerProfiles": "traf-", 95 | "networkVirtualNetworkGateways": "vgw-", 96 | "networkVirtualNetworks": "vnet-", 97 | "networkVirtualNetworksSubnets": "snet-", 98 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-", 99 | "networkVirtualWans": "vwan-", 100 | "networkVpnGateways": "vpng-", 101 | "networkVpnGatewaysVpnConnections": "vcn-", 102 | "networkVpnGatewaysVpnSites": "vst-", 103 | "notificationHubsNamespaces": "ntfns-", 104 | "notificationHubsNamespacesNotificationHubs": "ntf-", 105 | "operationalInsightsWorkspaces": "log-", 106 | "portalDashboards": "dash-", 107 | "powerBIDedicatedCapacities": "pbi-", 108 | "purviewAccounts": "pview-", 109 | "recoveryServicesVaults": "rsv-", 110 | "resourcesResourceGroups": "rg-", 111 | "searchSearchServices": "srch-", 112 | "serviceBusNamespaces": "sb-", 113 | "serviceBusNamespacesQueues": "sbq-", 114 | "serviceBusNamespacesTopics": "sbt-", 115 | "serviceEndPointPolicies": "se-", 116 | "serviceFabricClusters": "sf-", 117 | "signalRServiceSignalR": "sigr", 118 | "sqlManagedInstances": "sqlmi-", 119 | "sqlServers": "sql-", 120 | "sqlServersDataWarehouse": "sqldw-", 121 | "sqlServersDatabases": "sqldb-", 122 | "sqlServersDatabasesStretch": "sqlstrdb-", 123 | "storageStorageAccounts": "st", 124 | "storageStorageAccountsVm": "stvm", 125 | "storSimpleManagers": "ssimp", 126 | "streamAnalyticsCluster": "asa-", 127 | "synapseWorkspaces": "syn", 128 | "synapseWorkspacesAnalyticsWorkspaces": "synw", 129 | "synapseWorkspacesSqlPoolsDedicated": "syndp", 130 | "synapseWorkspacesSqlPoolsSpark": "synsp", 131 | "timeSeriesInsightsEnvironments": "tsi-", 132 | "webServerFarms": "plan-", 133 | "webSitesAppService": "app-", 134 | "webSitesAppServiceEnvironment": "ase-", 135 | "webSitesFunctions": "func-", 136 | "webStaticSites": "stapp-" 137 | } 138 | -------------------------------------------------------------------------------- /infra/app/api-appservice-avm.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param allowedOrigins array = [] 6 | param appCommandLine string? 7 | param appInsightResourceId string 8 | param appServicePlanId string 9 | @secure() 10 | param appSettings object = {} 11 | param siteConfig object = {} 12 | param serviceName string = 'api' 13 | 14 | @description('Required. Type of site to deploy.') 15 | param kind string 16 | 17 | @description('Optional. If client affinity is enabled.') 18 | param clientAffinityEnabled bool = true 19 | 20 | @description('Optional. Required if app of kind functionapp. Resource ID of the storage account to manage triggers and logging function executions.') 21 | param storageAccountResourceId string? 22 | 23 | module api 'br/public:avm/res/web/site:0.6.0' = { 24 | name: '${name}-app-module' 25 | params: { 26 | kind: kind 27 | name: name 28 | serverFarmResourceId: appServicePlanId 29 | tags: union(tags, { 'azd-service-name': serviceName }) 30 | location: location 31 | appInsightResourceId: appInsightResourceId 32 | clientAffinityEnabled: clientAffinityEnabled 33 | storageAccountResourceId: storageAccountResourceId 34 | managedIdentities: { 35 | systemAssigned: true 36 | } 37 | siteConfig: union(siteConfig, { 38 | cors: { 39 | allowedOrigins: union(['https://portal.azure.com', 'https://ms.portal.azure.com'], allowedOrigins) 40 | } 41 | appCommandLine: appCommandLine 42 | }) 43 | appSettingsKeyValuePairs: union( 44 | appSettings, 45 | { ENABLE_ORYX_BUILD: true, ApplicationInsightsAgent_EXTENSION_VERSION: contains(kind, 'linux') ? '~3' : '~2' } 46 | ) 47 | logsConfiguration: { 48 | applicationLogs: { fileSystem: { level: 'Verbose' } } 49 | detailedErrorMessages: { enabled: true } 50 | failedRequestsTracing: { enabled: true } 51 | httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } 52 | } 53 | } 54 | } 55 | 56 | output SERVICE_API_IDENTITY_PRINCIPAL_ID string = api.outputs.systemAssignedMIPrincipalId 57 | output SERVICE_API_NAME string = api.outputs.name 58 | output SERVICE_API_URI string = 'https://${api.outputs.defaultHostname}' 59 | -------------------------------------------------------------------------------- /infra/app/db-avm.bicep: -------------------------------------------------------------------------------- 1 | param accountName string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | param cosmosDatabaseName string = '' 5 | param keyVaultResourceId string 6 | param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' 7 | param collections array = [ 8 | { 9 | name: 'TodoList' 10 | id: 'TodoList' 11 | shardKey: { 12 | keys: [ 13 | 'Hash' 14 | ] 15 | } 16 | indexes: [ 17 | { 18 | key: { 19 | keys: [ 20 | '_id' 21 | ] 22 | } 23 | } 24 | ] 25 | } 26 | { 27 | name: 'TodoItem' 28 | id: 'TodoItem' 29 | shardKey: { 30 | keys: [ 31 | 'Hash' 32 | ] 33 | } 34 | indexes: [ 35 | { 36 | key: { 37 | keys: [ 38 | '_id' 39 | ] 40 | } 41 | } 42 | ] 43 | } 44 | ] 45 | 46 | var defaultDatabaseName = 'Todo' 47 | var actualDatabaseName = !empty(cosmosDatabaseName) ? cosmosDatabaseName : defaultDatabaseName 48 | 49 | module cosmos 'br/public:avm/res/document-db/database-account:0.6.0' = { 50 | name: 'cosmos-mongo' 51 | params: { 52 | locations: [ 53 | { 54 | failoverPriority: 0 55 | isZoneRedundant: false 56 | locationName: location 57 | } 58 | ] 59 | name: accountName 60 | location: location 61 | mongodbDatabases: [ 62 | { 63 | name: actualDatabaseName 64 | tags: tags 65 | collections: collections 66 | } 67 | ] 68 | secretsExportConfiguration: { 69 | keyVaultResourceId: keyVaultResourceId 70 | primaryWriteConnectionStringSecretName: connectionStringKey 71 | } 72 | } 73 | } 74 | 75 | output connectionStringKey string = connectionStringKey 76 | output databaseName string = actualDatabaseName 77 | output endpoint string = cosmos.outputs.endpoint 78 | -------------------------------------------------------------------------------- /infra/app/web-appservice-avm.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | param serviceName string = 'web' 5 | param appCommandLine string = 'pm2 serve /home/site/wwwroot --no-daemon --spa' 6 | param appInsightResourceId string 7 | param appServicePlanId string 8 | param linuxFxVersion string 9 | param kind string = 'app,linux' 10 | 11 | module web 'br/public:avm/res/web/site:0.6.0' = { 12 | name: '${name}-deployment' 13 | params: { 14 | kind: kind 15 | name: name 16 | serverFarmResourceId: appServicePlanId 17 | tags: union(tags, { 'azd-service-name': serviceName }) 18 | location: location 19 | appInsightResourceId: appInsightResourceId 20 | siteConfig: { 21 | appCommandLine: appCommandLine 22 | linuxFxVersion: linuxFxVersion 23 | alwaysOn: true 24 | } 25 | logsConfiguration: { 26 | applicationLogs: { fileSystem: { level: 'Verbose' } } 27 | detailedErrorMessages: { enabled: true } 28 | failedRequestsTracing: { enabled: true } 29 | httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } 30 | } 31 | appSettingsKeyValuePairs: { ApplicationInsightsAgent_EXTENSION_VERSION: contains(kind, 'linux') ? '~3' : '~2' } 32 | } 33 | } 34 | 35 | output SERVICE_WEB_IDENTITY_PRINCIPAL_ID string = web.outputs.systemAssignedMIPrincipalId 36 | output SERVICE_WEB_NAME string = web.outputs.name 37 | output SERVICE_WEB_URI string = 'https://${web.outputs.defaultHostname}' 38 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | @minLength(1) 4 | @maxLength(64) 5 | @description('Name of the the environment which is used to generate a short unique hash used in all resources.') 6 | param environmentName string 7 | 8 | @minLength(1) 9 | @description('Primary location for all resources') 10 | param location string 11 | 12 | // Optional parameters to override the default azd resource naming conventions. Update the main.parameters.json file to provide values. e.g.,: 13 | // "resourceGroupName": { 14 | // "value": "myGroupName" 15 | // } 16 | param apiServiceName string = '' 17 | param applicationInsightsDashboardName string = '' 18 | param applicationInsightsName string = '' 19 | param appServicePlanName string = '' 20 | param cosmosAccountName string = '' 21 | param keyVaultName string = '' 22 | param logAnalyticsName string = '' 23 | param resourceGroupName string = '' 24 | param webServiceName string = '' 25 | param apimServiceName string = '' 26 | 27 | @description('Flag to use Azure API Management to mediate the calls between the Web frontend and the backend API') 28 | param useAPIM bool = false 29 | 30 | @description('API Management SKU to use if APIM is enabled') 31 | param apimSku string = 'Consumption' 32 | 33 | @description('Id of the user or app to assign application roles') 34 | param principalId string = '' 35 | 36 | var abbrs = loadJsonContent('./abbreviations.json') 37 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 38 | var tags = { 'azd-env-name': environmentName } 39 | 40 | // Organize resources in a resource group 41 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 42 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 43 | location: location 44 | tags: tags 45 | } 46 | 47 | // The application frontend 48 | module web './app/web-appservice-avm.bicep' = { 49 | name: 'web' 50 | scope: rg 51 | params: { 52 | name: !empty(webServiceName) ? webServiceName : '${abbrs.webSitesAppService}web-${resourceToken}' 53 | location: location 54 | tags: tags 55 | appServicePlanId: appServicePlan.outputs.resourceId 56 | appInsightResourceId: monitoring.outputs.applicationInsightsResourceId 57 | linuxFxVersion: 'node|20-lts' 58 | } 59 | } 60 | 61 | // The application backend 62 | module api './app/api-appservice-avm.bicep' = { 63 | name: 'api' 64 | scope: rg 65 | params: { 66 | name: !empty(apiServiceName) ? apiServiceName : '${abbrs.webSitesAppService}api-${resourceToken}' 67 | location: location 68 | tags: tags 69 | kind: 'app' 70 | appServicePlanId: appServicePlan.outputs.resourceId 71 | siteConfig: { 72 | alwaysOn: true 73 | linuxFxVersion: 'node|20-lts' 74 | } 75 | appSettings: { 76 | AZURE_KEY_VAULT_ENDPOINT: keyVault.outputs.uri 77 | AZURE_COSMOS_CONNECTION_STRING_KEY: cosmos.outputs.connectionStringKey 78 | AZURE_COSMOS_DATABASE_NAME: cosmos.outputs.databaseName 79 | AZURE_COSMOS_ENDPOINT: 'https://${cosmos.outputs.databaseName}.documents.azure.com:443/' 80 | API_ALLOW_ORIGINS: web.outputs.SERVICE_WEB_URI 81 | SCM_DO_BUILD_DURING_DEPLOYMENT: true 82 | } 83 | appInsightResourceId: monitoring.outputs.applicationInsightsResourceId 84 | allowedOrigins: [ web.outputs.SERVICE_WEB_URI ] 85 | } 86 | } 87 | 88 | // Give the API access to KeyVault 89 | module accessKeyVault 'br/public:avm/res/key-vault/vault:0.3.5' = { 90 | name: 'accesskeyvault' 91 | scope: rg 92 | params: { 93 | name: keyVault.outputs.name 94 | enableRbacAuthorization: false 95 | enableVaultForDeployment: false 96 | enableVaultForTemplateDeployment: false 97 | enablePurgeProtection: false 98 | sku: 'standard' 99 | accessPolicies: [ 100 | { 101 | objectId: principalId 102 | permissions: { 103 | secrets: [ 'get', 'list' ] 104 | } 105 | } 106 | { 107 | objectId: api.outputs.SERVICE_API_IDENTITY_PRINCIPAL_ID 108 | permissions: { 109 | secrets: [ 'get', 'list' ] 110 | } 111 | } 112 | ] 113 | } 114 | } 115 | 116 | // The application database 117 | module cosmos './app/db-avm.bicep' = { 118 | name: 'cosmos' 119 | scope: rg 120 | params: { 121 | accountName: !empty(cosmosAccountName) ? cosmosAccountName : '${abbrs.documentDBDatabaseAccounts}${resourceToken}' 122 | location: location 123 | tags: tags 124 | keyVaultResourceId: keyVault.outputs.resourceId 125 | } 126 | } 127 | 128 | // Create an App Service Plan to group applications under the same payment plan and SKU 129 | module appServicePlan 'br/public:avm/res/web/serverfarm:0.1.0' = { 130 | name: 'appserviceplan' 131 | scope: rg 132 | params: { 133 | name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' 134 | sku: { 135 | name: 'B3' 136 | tier: 'Basic' 137 | } 138 | location: location 139 | tags: tags 140 | reserved: true 141 | kind: 'Linux' 142 | } 143 | } 144 | 145 | // Create a keyvault to store secrets 146 | module keyVault 'br/public:avm/res/key-vault/vault:0.3.5' = { 147 | name: 'keyvault' 148 | scope: rg 149 | params: { 150 | name: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' 151 | location: location 152 | tags: tags 153 | enableRbacAuthorization: false 154 | enableVaultForDeployment: false 155 | enableVaultForTemplateDeployment: false 156 | enablePurgeProtection: false 157 | sku: 'standard' 158 | } 159 | } 160 | 161 | // Monitor application with Azure Monitor 162 | module monitoring 'br/public:avm/ptn/azd/monitoring:0.1.0' = { 163 | name: 'monitoring' 164 | scope: rg 165 | params: { 166 | applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' 167 | logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 168 | applicationInsightsDashboardName: !empty(applicationInsightsDashboardName) ? applicationInsightsDashboardName : '${abbrs.portalDashboards}${resourceToken}' 169 | location: location 170 | tags: tags 171 | } 172 | } 173 | // Creates Azure API Management (APIM) service to mediate the requests between the frontend and the backend API 174 | module apim 'br/public:avm/res/api-management/service:0.2.0' = if (useAPIM) { 175 | name: 'apim-deployment' 176 | scope: rg 177 | params: { 178 | name: !empty(apimServiceName) ? apimServiceName : '${abbrs.apiManagementService}${resourceToken}' 179 | publisherEmail: 'noreply@microsoft.com' 180 | publisherName: 'n/a' 181 | location: location 182 | tags: tags 183 | sku: apimSku 184 | skuCount: 0 185 | zones: [] 186 | customProperties: {} 187 | loggers: [ 188 | { 189 | name: 'app-insights-logger' 190 | credentials: { 191 | instrumentationKey: monitoring.outputs.applicationInsightsInstrumentationKey 192 | } 193 | loggerDescription: 'Logger to Azure Application Insights' 194 | isBuffered: false 195 | loggerType: 'applicationInsights' 196 | targetResourceId: monitoring.outputs.applicationInsightsResourceId 197 | } 198 | ] 199 | } 200 | } 201 | 202 | //Configures the API settings for an api app within the Azure API Management (APIM) service. 203 | module apimApi 'br/public:avm/ptn/azd/apim-api:0.1.0' = if (useAPIM) { 204 | name: 'apim-api-deployment' 205 | scope: rg 206 | params: { 207 | apiBackendUrl: api.outputs.SERVICE_API_URI 208 | apiDescription: 'This is a simple Todo API' 209 | apiDisplayName: 'Simple Todo API' 210 | apiName: 'todo-api' 211 | apiPath: 'todo' 212 | name: useAPIM ? apim.outputs.name : '' 213 | webFrontendUrl: web.outputs.SERVICE_WEB_URI 214 | location: location 215 | apiAppName: api.outputs.SERVICE_API_NAME 216 | } 217 | } 218 | 219 | // Data outputs 220 | output AZURE_COSMOS_CONNECTION_STRING_KEY string = cosmos.outputs.connectionStringKey 221 | output AZURE_COSMOS_DATABASE_NAME string = cosmos.outputs.databaseName 222 | 223 | // App outputs 224 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString 225 | output AZURE_KEY_VAULT_ENDPOINT string = keyVault.outputs.uri 226 | output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name 227 | output AZURE_LOCATION string = location 228 | output AZURE_TENANT_ID string = tenant().tenantId 229 | output API_BASE_URL string = useAPIM ? apimApi.outputs.serviceApiUri : api.outputs.SERVICE_API_URI 230 | output REACT_APP_WEB_BASE_URL string = web.outputs.SERVICE_WEB_URI 231 | output USE_APIM bool = useAPIM 232 | output SERVICE_API_ENDPOINTS array = useAPIM ? [ apimApi.outputs.serviceApiUri, api.outputs.SERVICE_API_URI ]: [] 233 | -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "environmentName": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "principalId": { 12 | "value": "${AZURE_PRINCIPAL_ID}" 13 | }, 14 | "useAPIM": { 15 | "value": "${USE_APIM=false}" 16 | }, 17 | "apimSku": { 18 | "value": "${APIM_SKU=Consumption}" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: Simple Todo API 4 | version: 3.0.0 5 | title: Simple Todo API 6 | contact: 7 | email: azdevteam@microsoft.com 8 | 9 | components: 10 | schemas: 11 | TodoItem: 12 | type: object 13 | required: 14 | - listId 15 | - name 16 | - description 17 | description: A task that needs to be completed 18 | properties: 19 | id: 20 | type: string 21 | listId: 22 | type: string 23 | name: 24 | type: string 25 | description: 26 | type: string 27 | state: 28 | $ref: "#/components/schemas/TodoState" 29 | dueDate: 30 | type: string 31 | format: date-time 32 | completedDate: 33 | type: string 34 | format: date-time 35 | TodoList: 36 | type: object 37 | required: 38 | - name 39 | properties: 40 | id: 41 | type: string 42 | name: 43 | type: string 44 | description: 45 | type: string 46 | description: " A list of related Todo items" 47 | TodoState: 48 | type: string 49 | enum: 50 | - todo 51 | - inprogress 52 | - done 53 | parameters: 54 | listId: 55 | in: path 56 | required: true 57 | name: listId 58 | description: The Todo list unique identifier 59 | schema: 60 | type: string 61 | itemId: 62 | in: path 63 | required: true 64 | name: itemId 65 | description: The Todo item unique identifier 66 | schema: 67 | type: string 68 | state: 69 | in: path 70 | required: true 71 | name: state 72 | description: The Todo item state 73 | schema: 74 | $ref: "#/components/schemas/TodoState" 75 | top: 76 | in: query 77 | required: false 78 | name: top 79 | description: The max number of items to returns in a result 80 | schema: 81 | type: number 82 | default: 20 83 | skip: 84 | in: query 85 | required: false 86 | name: skip 87 | description: The number of items to skip within the results 88 | schema: 89 | type: number 90 | default: 0 91 | 92 | requestBodies: 93 | TodoList: 94 | description: The Todo List 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/TodoList" 99 | TodoItem: 100 | description: The Todo Item 101 | content: 102 | application/json: 103 | schema: 104 | $ref: "#/components/schemas/TodoItem" 105 | 106 | responses: 107 | TodoList: 108 | description: A Todo list result 109 | content: 110 | application/json: 111 | schema: 112 | $ref: "#/components/schemas/TodoList" 113 | TodoListArray: 114 | description: An array of Todo lists 115 | content: 116 | application/json: 117 | schema: 118 | type: array 119 | items: 120 | $ref: "#/components/schemas/TodoList" 121 | TodoItem: 122 | description: A Todo item result 123 | content: 124 | application/json: 125 | schema: 126 | $ref: "#/components/schemas/TodoItem" 127 | TodoItemArray: 128 | description: An array of Todo items 129 | content: 130 | application/json: 131 | schema: 132 | type: array 133 | items: 134 | $ref: "#/components/schemas/TodoItem" 135 | 136 | paths: 137 | /lists: 138 | get: 139 | operationId: GetLists 140 | summary: Gets an array of Todo lists 141 | tags: 142 | - Lists 143 | parameters: 144 | - $ref: "#/components/parameters/top" 145 | - $ref: "#/components/parameters/skip" 146 | responses: 147 | 200: 148 | $ref: "#/components/responses/TodoListArray" 149 | post: 150 | operationId: CreateList 151 | summary: Creates a new Todo list 152 | tags: 153 | - Lists 154 | requestBody: 155 | $ref: "#/components/requestBodies/TodoList" 156 | responses: 157 | 201: 158 | $ref: "#/components/responses/TodoList" 159 | 400: 160 | description: Invalid request schema 161 | /lists/{listId}: 162 | get: 163 | operationId: GetListById 164 | summary: Gets a Todo list by unique identifier 165 | tags: 166 | - Lists 167 | parameters: 168 | - $ref: "#/components/parameters/listId" 169 | responses: 170 | 200: 171 | $ref: "#/components/responses/TodoList" 172 | 404: 173 | description: Todo list not found 174 | put: 175 | operationId: UpdateListById 176 | summary: Updates a Todo list by unique identifier 177 | tags: 178 | - Lists 179 | requestBody: 180 | $ref: "#/components/requestBodies/TodoList" 181 | parameters: 182 | - $ref: "#/components/parameters/listId" 183 | responses: 184 | 200: 185 | $ref: "#/components/responses/TodoList" 186 | 404: 187 | description: Todo list not found 188 | 400: 189 | description: Todo list is invalid 190 | delete: 191 | operationId: DeleteListById 192 | summary: Deletes a Todo list by unique identifier 193 | tags: 194 | - Lists 195 | parameters: 196 | - $ref: "#/components/parameters/listId" 197 | responses: 198 | 204: 199 | description: Todo list deleted successfully 200 | 404: 201 | description: Todo list not found 202 | /lists/{listId}/items: 203 | post: 204 | operationId: CreateItem 205 | summary: Creates a new Todo item within a list 206 | tags: 207 | - Items 208 | requestBody: 209 | $ref: "#/components/requestBodies/TodoItem" 210 | parameters: 211 | - $ref: "#/components/parameters/listId" 212 | responses: 213 | 201: 214 | $ref: "#/components/responses/TodoItem" 215 | 404: 216 | description: Todo list not found 217 | get: 218 | operationId: GetItemsByListId 219 | summary: Gets Todo items within the specified list 220 | tags: 221 | - Items 222 | parameters: 223 | - $ref: "#/components/parameters/listId" 224 | - $ref: "#/components/parameters/top" 225 | - $ref: "#/components/parameters/skip" 226 | responses: 227 | 200: 228 | $ref: "#/components/responses/TodoItemArray" 229 | 404: 230 | description: Todo list not found 231 | /lists/{listId}/items/{itemId}: 232 | get: 233 | operationId: GetItemById 234 | summary: Gets a Todo item by unique identifier 235 | tags: 236 | - Items 237 | parameters: 238 | - $ref: "#/components/parameters/listId" 239 | - $ref: "#/components/parameters/itemId" 240 | responses: 241 | 200: 242 | $ref: "#/components/responses/TodoItem" 243 | 404: 244 | description: Todo list or item not found 245 | put: 246 | operationId: UpdateItemById 247 | summary: Updates a Todo item by unique identifier 248 | tags: 249 | - Items 250 | requestBody: 251 | $ref: "#/components/requestBodies/TodoItem" 252 | parameters: 253 | - $ref: "#/components/parameters/listId" 254 | - $ref: "#/components/parameters/itemId" 255 | responses: 256 | 200: 257 | $ref: "#/components/responses/TodoItem" 258 | 400: 259 | description: Todo item is invalid 260 | 404: 261 | description: Todo list or item not found 262 | delete: 263 | operationId: DeleteItemById 264 | summary: Deletes a Todo item by unique identifier 265 | tags: 266 | - Items 267 | parameters: 268 | - $ref: "#/components/parameters/listId" 269 | - $ref: "#/components/parameters/itemId" 270 | responses: 271 | 204: 272 | description: Todo item deleted successfully 273 | 404: 274 | description: Todo list or item not found 275 | /lists/{listId}/items/state/{state}: 276 | get: 277 | operationId: GetItemsByListIdAndState 278 | summary: Gets a list of Todo items of a specific state 279 | tags: 280 | - Items 281 | parameters: 282 | - $ref: "#/components/parameters/listId" 283 | - $ref: "#/components/parameters/state" 284 | - $ref: "#/components/parameters/top" 285 | - $ref: "#/components/parameters/skip" 286 | responses: 287 | 200: 288 | $ref: "#/components/responses/TodoItemArray" 289 | 404: 290 | description: Todo list or item not found 291 | put: 292 | operationId: UpdateItemsStateByListId 293 | summary: Changes the state of the specified list items 294 | tags: 295 | - Items 296 | requestBody: 297 | description: unique identifiers of the Todo items to update 298 | content: 299 | application/json: 300 | schema: 301 | type: array 302 | items: 303 | description: The Todo item unique identifier 304 | type: string 305 | parameters: 306 | - $ref: "#/components/parameters/listId" 307 | - $ref: "#/components/parameters/state" 308 | responses: 309 | 204: 310 | description: Todo items updated 311 | 400: 312 | description: Update request is invalid 313 | -------------------------------------------------------------------------------- /src/api/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.devcontainer 2 | **/dist 3 | **/node_modules 4 | README.md 5 | -------------------------------------------------------------------------------- /src/api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": 13, 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | "indent": [ 20 | "error", 21 | 4 22 | ], 23 | "quotes": [ 24 | "error", 25 | "double" 26 | ], 27 | "semi": [ 28 | "error", 29 | "always" 30 | ], 31 | "@typescript-eslint/no-explicit-any": [ 32 | "off" 33 | ] 34 | } 35 | } -------------------------------------------------------------------------------- /src/api/.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /src/api/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Azure Functions artifacts 107 | local.settings.json -------------------------------------------------------------------------------- /src/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS build 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN npm ci 8 | RUN npm run build 9 | 10 | CMD ["npm","run","start"] 11 | -------------------------------------------------------------------------------- /src/api/README.md: -------------------------------------------------------------------------------- 1 | # Node with Typescript Express REST API 2 | 3 | ## Setup 4 | 5 | ### Prerequisites 6 | 7 | - Node (18.17.1) 8 | - NPM (9.8.1) 9 | 10 | ### Local Environment 11 | 12 | Create a `.env` with the following configuration: 13 | 14 | - `AZURE_COSMOS_CONNECTION_STRING` - Cosmos DB connection string (Mongo DB also supported) 15 | - `AZURE_COSMOS_DATABASE_NAME` - Cosmos DB database name (Will automatically be created if it doesn't exist) (default: Todo) 16 | - `APPLICATIONINSIGHTS_CONNECTION_STRING` - Azure Application Insights connection string 17 | - `APPLICATIONINSIGHTS_ROLE_NAME` - Azure Application Insights Role name (default: API) 18 | 19 | ### Install Dependencies 20 | 21 | Run `npm ci` to install local dependencies 22 | 23 | ### Build & Compile 24 | 25 | Run `npm run build` to build & compile the Typescript code into the `./dist` folder 26 | 27 | ### Run application 28 | 29 | Run `npm start` to start the local development server 30 | 31 | Launch browser @ `http://localhost:3100`. The default page hosts the Open API UI where you can try out the API 32 | -------------------------------------------------------------------------------- /src/api/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | }; -------------------------------------------------------------------------------- /src/api/config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "connectionString": "AZURE_COSMOS_CONNECTION_STRING", 4 | "databaseName": "AZURE_COSMOS_DATABASE_NAME" 5 | }, 6 | "observability": { 7 | "connectionString": "APPLICATIONINSIGHTS_CONNECTION_STRING", 8 | "roleName": "APPLICATIONINSIGHTS_ROLE_NAME" 9 | } 10 | } -------------------------------------------------------------------------------- /src/api/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "connectionString": "", 4 | "databaseName": "Todo" 5 | }, 6 | "observability": { 7 | "connectionString": "", 8 | "roleName": "API" 9 | } 10 | } -------------------------------------------------------------------------------- /src/api/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: Simple Todo API 4 | version: 3.0.0 5 | title: Simple Todo API 6 | contact: 7 | email: azdevteam@microsoft.com 8 | 9 | components: 10 | schemas: 11 | TodoItem: 12 | type: object 13 | required: 14 | - listId 15 | - name 16 | - description 17 | description: A task that needs to be completed 18 | properties: 19 | id: 20 | type: string 21 | listId: 22 | type: string 23 | name: 24 | type: string 25 | description: 26 | type: string 27 | state: 28 | $ref: "#/components/schemas/TodoState" 29 | dueDate: 30 | type: string 31 | format: date-time 32 | completedDate: 33 | type: string 34 | format: date-time 35 | TodoList: 36 | type: object 37 | required: 38 | - name 39 | properties: 40 | id: 41 | type: string 42 | name: 43 | type: string 44 | description: 45 | type: string 46 | description: " A list of related Todo items" 47 | TodoState: 48 | type: string 49 | enum: 50 | - todo 51 | - inprogress 52 | - done 53 | parameters: 54 | listId: 55 | in: path 56 | required: true 57 | name: listId 58 | description: The Todo list unique identifier 59 | schema: 60 | type: string 61 | itemId: 62 | in: path 63 | required: true 64 | name: itemId 65 | description: The Todo item unique identifier 66 | schema: 67 | type: string 68 | state: 69 | in: path 70 | required: true 71 | name: state 72 | description: The Todo item state 73 | schema: 74 | $ref: "#/components/schemas/TodoState" 75 | top: 76 | in: query 77 | required: false 78 | name: top 79 | description: The max number of items to returns in a result 80 | schema: 81 | type: number 82 | default: 20 83 | skip: 84 | in: query 85 | required: false 86 | name: skip 87 | description: The number of items to skip within the results 88 | schema: 89 | type: number 90 | default: 0 91 | 92 | requestBodies: 93 | TodoList: 94 | description: The Todo List 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/TodoList" 99 | TodoItem: 100 | description: The Todo Item 101 | content: 102 | application/json: 103 | schema: 104 | $ref: "#/components/schemas/TodoItem" 105 | 106 | responses: 107 | TodoList: 108 | description: A Todo list result 109 | content: 110 | application/json: 111 | schema: 112 | $ref: "#/components/schemas/TodoList" 113 | TodoListArray: 114 | description: An array of Todo lists 115 | content: 116 | application/json: 117 | schema: 118 | type: array 119 | items: 120 | $ref: "#/components/schemas/TodoList" 121 | TodoItem: 122 | description: A Todo item result 123 | content: 124 | application/json: 125 | schema: 126 | $ref: "#/components/schemas/TodoItem" 127 | TodoItemArray: 128 | description: An array of Todo items 129 | content: 130 | application/json: 131 | schema: 132 | type: array 133 | items: 134 | $ref: "#/components/schemas/TodoItem" 135 | 136 | paths: 137 | /lists: 138 | get: 139 | operationId: GetLists 140 | summary: Gets an array of Todo lists 141 | tags: 142 | - Lists 143 | parameters: 144 | - $ref: "#/components/parameters/top" 145 | - $ref: "#/components/parameters/skip" 146 | responses: 147 | 200: 148 | $ref: "#/components/responses/TodoListArray" 149 | post: 150 | operationId: CreateList 151 | summary: Creates a new Todo list 152 | tags: 153 | - Lists 154 | requestBody: 155 | $ref: "#/components/requestBodies/TodoList" 156 | responses: 157 | 201: 158 | $ref: "#/components/responses/TodoList" 159 | 400: 160 | description: Invalid request schema 161 | /lists/{listId}: 162 | get: 163 | operationId: GetListById 164 | summary: Gets a Todo list by unique identifier 165 | tags: 166 | - Lists 167 | parameters: 168 | - $ref: "#/components/parameters/listId" 169 | responses: 170 | 200: 171 | $ref: "#/components/responses/TodoList" 172 | 404: 173 | description: Todo list not found 174 | put: 175 | operationId: UpdateListById 176 | summary: Updates a Todo list by unique identifier 177 | tags: 178 | - Lists 179 | requestBody: 180 | $ref: "#/components/requestBodies/TodoList" 181 | parameters: 182 | - $ref: "#/components/parameters/listId" 183 | responses: 184 | 200: 185 | $ref: "#/components/responses/TodoList" 186 | 404: 187 | description: Todo list not found 188 | 400: 189 | description: Todo list is invalid 190 | delete: 191 | operationId: DeleteListById 192 | summary: Deletes a Todo list by unique identifier 193 | tags: 194 | - Lists 195 | parameters: 196 | - $ref: "#/components/parameters/listId" 197 | responses: 198 | 204: 199 | description: Todo list deleted successfully 200 | 404: 201 | description: Todo list not found 202 | /lists/{listId}/items: 203 | post: 204 | operationId: CreateItem 205 | summary: Creates a new Todo item within a list 206 | tags: 207 | - Items 208 | requestBody: 209 | $ref: "#/components/requestBodies/TodoItem" 210 | parameters: 211 | - $ref: "#/components/parameters/listId" 212 | responses: 213 | 201: 214 | $ref: "#/components/responses/TodoItem" 215 | 404: 216 | description: Todo list not found 217 | get: 218 | operationId: GetItemsByListId 219 | summary: Gets Todo items within the specified list 220 | tags: 221 | - Items 222 | parameters: 223 | - $ref: "#/components/parameters/listId" 224 | - $ref: "#/components/parameters/top" 225 | - $ref: "#/components/parameters/skip" 226 | responses: 227 | 200: 228 | $ref: "#/components/responses/TodoItemArray" 229 | 404: 230 | description: Todo list not found 231 | /lists/{listId}/items/{itemId}: 232 | get: 233 | operationId: GetItemById 234 | summary: Gets a Todo item by unique identifier 235 | tags: 236 | - Items 237 | parameters: 238 | - $ref: "#/components/parameters/listId" 239 | - $ref: "#/components/parameters/itemId" 240 | responses: 241 | 200: 242 | $ref: "#/components/responses/TodoItem" 243 | 404: 244 | description: Todo list or item not found 245 | put: 246 | operationId: UpdateItemById 247 | summary: Updates a Todo item by unique identifier 248 | tags: 249 | - Items 250 | requestBody: 251 | $ref: "#/components/requestBodies/TodoItem" 252 | parameters: 253 | - $ref: "#/components/parameters/listId" 254 | - $ref: "#/components/parameters/itemId" 255 | responses: 256 | 200: 257 | $ref: "#/components/responses/TodoItem" 258 | 400: 259 | description: Todo item is invalid 260 | 404: 261 | description: Todo list or item not found 262 | delete: 263 | operationId: DeleteItemById 264 | summary: Deletes a Todo item by unique identifier 265 | tags: 266 | - Items 267 | parameters: 268 | - $ref: "#/components/parameters/listId" 269 | - $ref: "#/components/parameters/itemId" 270 | responses: 271 | 204: 272 | description: Todo item deleted successfully 273 | 404: 274 | description: Todo list or item not found 275 | /lists/{listId}/items/state/{state}: 276 | get: 277 | operationId: GetItemsByListIdAndState 278 | summary: Gets a list of Todo items of a specific state 279 | tags: 280 | - Items 281 | parameters: 282 | - $ref: "#/components/parameters/listId" 283 | - $ref: "#/components/parameters/state" 284 | - $ref: "#/components/parameters/top" 285 | - $ref: "#/components/parameters/skip" 286 | responses: 287 | 200: 288 | $ref: "#/components/responses/TodoItemArray" 289 | 404: 290 | description: Todo list or item not found 291 | put: 292 | operationId: UpdateItemsStateByListId 293 | summary: Changes the state of the specified list items 294 | tags: 295 | - Items 296 | requestBody: 297 | description: unique identifiers of the Todo items to update 298 | content: 299 | application/json: 300 | schema: 301 | type: array 302 | items: 303 | description: The Todo item unique identifier 304 | type: string 305 | parameters: 306 | - $ref: "#/components/parameters/listId" 307 | - $ref: "#/components/parameters/state" 308 | responses: 309 | 204: 310 | description: Todo items updated 311 | 400: 312 | description: Update request is invalid 313 | -------------------------------------------------------------------------------- /src/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-node-ts-express-mongo", 3 | "version": "0.1.0", 4 | "description": "Express Todo REST API", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "lint": "eslint src/**/*.ts", 8 | "lint:fix": "eslint src/**/*.ts --fix", 9 | "prebuild": "npm run lint", 10 | "build": "tsc -b .", 11 | "prestart": "npm run build", 12 | "start": "node .", 13 | "test": "jest --coverage" 14 | }, 15 | "author": "", 16 | "license": "MIT", 17 | "jest": { 18 | "testEnvironment": "node", 19 | "coveragePathIgnorePatterns": [ 20 | "/node_modules/" 21 | ], 22 | "testMatch": [ 23 | "/**/*.spec.ts" 24 | ] 25 | }, 26 | "dependencies": { 27 | "@azure/identity": "4.2.1", 28 | "@azure/keyvault-secrets": "^4.7.0", 29 | "applicationinsights": "^2.5.1", 30 | "config": "^3.3.12", 31 | "cors": "^2.8.5", 32 | "dotenv": "^16.0.1", 33 | "express": "^4.21.2", 34 | "mongodb": "^4.17.0", 35 | "mongoose": "^8.9.5", 36 | "swagger-jsdoc": "^6.2.1", 37 | "swagger-ui-express": "^4.4.0", 38 | "winston": "^3.7.2", 39 | "winston-transport": "^4.5.0", 40 | "yamljs": "^0.3.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/preset-env": "^7.18.2", 44 | "@babel/preset-typescript": "^7.17.12", 45 | "@types/config": "^0.0.41", 46 | "@types/cors": "^2.8.12", 47 | "@types/express": "^4.17.13", 48 | "@types/jest": "^28.1.1", 49 | "@types/supertest": "^2.0.12", 50 | "@types/swagger-jsdoc": "^6.0.1", 51 | "@types/swagger-ui-express": "^4.1.3", 52 | "@types/uuid": "^8.3.4", 53 | "@types/yamljs": "^0.2.31", 54 | "@typescript-eslint/eslint-plugin": "^5.27.1", 55 | "@typescript-eslint/parser": "^5.27.1", 56 | "eslint": "^8.17.0", 57 | "jest": "^28.1.1", 58 | "supertest": "^6.2.3", 59 | "ts-jest": "^28.0.4", 60 | "typescript": "^4.7.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/api/src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from "express"; 2 | import swaggerUI from "swagger-ui-express"; 3 | import cors from "cors"; 4 | import yaml from "yamljs"; 5 | import { getConfig } from "./config"; 6 | import lists from "./routes/lists"; 7 | import items from "./routes/items"; 8 | import { configureMongoose } from "./models/mongoose"; 9 | import { observability } from "./config/observability"; 10 | 11 | // Use API_ALLOW_ORIGINS env var with comma separated urls like 12 | // `http://localhost:300, http://otherurl:100` 13 | // Requests coming to the api server from other urls will be rejected as per 14 | // CORS. 15 | const allowOrigins = process.env.API_ALLOW_ORIGINS; 16 | 17 | // Use NODE_ENV to change webConfiguration based on this value. 18 | // For example, setting NODE_ENV=development disables CORS checking, 19 | // allowing all origins. 20 | const environment = process.env.NODE_ENV; 21 | 22 | const originList = ():string[]|string => { 23 | 24 | if (environment && environment === "development") { 25 | console.log(`Allowing requests from any origins. NODE_ENV=${environment}`); 26 | return "*"; 27 | } 28 | 29 | const origins = [ 30 | "https://portal.azure.com", 31 | "https://ms.portal.azure.com", 32 | ]; 33 | 34 | if (allowOrigins && allowOrigins !== "") { 35 | allowOrigins.split(",").forEach(origin => { 36 | origins.push(origin); 37 | }); 38 | } 39 | 40 | return origins; 41 | }; 42 | 43 | export const createApp = async (): Promise => { 44 | const config = await getConfig(); 45 | const app = express(); 46 | 47 | // Configuration 48 | observability(config.observability); 49 | await configureMongoose(config.database); 50 | // Middleware 51 | app.use(express.json()); 52 | 53 | app.use(cors({ 54 | origin: originList() 55 | })); 56 | 57 | // API Routes 58 | app.use("/lists/:listId/items", items); 59 | app.use("/lists", lists); 60 | 61 | // Swagger UI 62 | const swaggerDocument = yaml.load("./openapi.yaml"); 63 | app.use("/", swaggerUI.serve, swaggerUI.setup(swaggerDocument)); 64 | 65 | return app; 66 | }; 67 | -------------------------------------------------------------------------------- /src/api/src/config/appConfig.ts: -------------------------------------------------------------------------------- 1 | export interface ObservabilityConfig { 2 | connectionString: string 3 | roleName: string 4 | } 5 | 6 | export interface DatabaseConfig { 7 | connectionString: string 8 | databaseName: string 9 | } 10 | 11 | export interface AppConfig { 12 | observability: ObservabilityConfig 13 | database: DatabaseConfig 14 | } 15 | -------------------------------------------------------------------------------- /src/api/src/config/applicationInsightsTransport.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryClient } from "applicationinsights"; 2 | import { SeverityLevel, TraceTelemetry } from "applicationinsights/out/Declarations/Contracts"; 3 | import Transport, { TransportStreamOptions } from "winston-transport"; 4 | import { LogEntry } from "winston"; 5 | import { LogLevel } from "./observability"; 6 | 7 | export interface ApplicationInsightsTransportOptions extends TransportStreamOptions { 8 | client: TelemetryClient 9 | handleRejections?: boolean; 10 | } 11 | 12 | export class ApplicationInsightsTransport extends Transport { 13 | private client: TelemetryClient; 14 | 15 | constructor(opts: ApplicationInsightsTransportOptions) { 16 | super(opts); 17 | this.client = opts.client; 18 | } 19 | 20 | public log(info: LogEntry, callback: () => void) { 21 | const telemetry: TraceTelemetry = { 22 | severity: convertToSeverity(info.level), 23 | message: info.message, 24 | }; 25 | 26 | this.client.trackTrace(telemetry); 27 | callback(); 28 | } 29 | } 30 | 31 | const convertToSeverity = (level: LogLevel | string): SeverityLevel => { 32 | switch (level) { 33 | case LogLevel.Debug: 34 | return SeverityLevel.Verbose; 35 | case LogLevel.Verbose: 36 | return SeverityLevel.Verbose; 37 | case LogLevel.Error: 38 | return SeverityLevel.Error; 39 | case LogLevel.Warning: 40 | return SeverityLevel.Warning; 41 | case LogLevel.Information: 42 | return SeverityLevel.Information; 43 | default: 44 | return SeverityLevel.Verbose; 45 | } 46 | }; -------------------------------------------------------------------------------- /src/api/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig, DatabaseConfig, ObservabilityConfig } from "./appConfig"; 2 | import dotenv from "dotenv"; 3 | import { DefaultAzureCredential } from "@azure/identity"; 4 | import { SecretClient } from "@azure/keyvault-secrets"; 5 | import { logger } from "../config/observability"; 6 | import { IConfig } from "config"; 7 | 8 | export const getConfig: () => Promise = async () => { 9 | // Load any ENV vars from local .env file 10 | if (process.env.NODE_ENV !== "production") { 11 | dotenv.config(); 12 | } 13 | 14 | await populateEnvironmentFromKeyVault(); 15 | 16 | // Load configuration after Azure KeyVault population is complete 17 | // eslint-disable-next-line @typescript-eslint/no-var-requires 18 | const config: IConfig = require("config") as IConfig; 19 | const databaseConfig = config.get("database"); 20 | const observabilityConfig = config.get("observability"); 21 | 22 | if (!databaseConfig.connectionString) { 23 | logger.warn("database.connectionString is required but has not been set. Ensure environment variable 'AZURE_COSMOS_CONNECTION_STRING' has been set"); 24 | } 25 | 26 | if (!observabilityConfig.connectionString) { 27 | logger.warn("observability.connectionString is required but has not been set. Ensure environment variable 'APPLICATIONINSIGHTS_CONNECTION_STRING' has been set"); 28 | } 29 | 30 | return { 31 | observability: { 32 | connectionString: observabilityConfig.connectionString, 33 | roleName: observabilityConfig.roleName, 34 | }, 35 | database: { 36 | connectionString: databaseConfig.connectionString, 37 | databaseName: databaseConfig.databaseName, 38 | }, 39 | }; 40 | }; 41 | 42 | const populateEnvironmentFromKeyVault = async () => { 43 | // If Azure key vault endpoint is defined 44 | // 1. Login with Default credential (managed identity or service principal) 45 | // 2. Overlay key vault secrets on top of ENV vars 46 | const keyVaultEndpoint = process.env.AZURE_KEY_VAULT_ENDPOINT || ""; 47 | 48 | if (!keyVaultEndpoint) { 49 | logger.warn("AZURE_KEY_VAULT_ENDPOINT has not been set. Configuration will be loaded from current environment."); 50 | return; 51 | } 52 | 53 | try { 54 | logger.info("Populating environment from Azure KeyVault..."); 55 | const credential = new DefaultAzureCredential({}); 56 | const secretClient = new SecretClient(keyVaultEndpoint, credential); 57 | 58 | for await (const secretProperties of secretClient.listPropertiesOfSecrets()) { 59 | const secret = await secretClient.getSecret(secretProperties.name); 60 | 61 | // KeyVault does not support underscores in key names and replaces '-' with '_' 62 | // Expect KeyVault secret names to be in conventional capitalized snake casing after conversion 63 | const keyName = secret.name.replace(/-/g, "_"); 64 | process.env[keyName] = secret.value; 65 | } 66 | } 67 | catch (err: any) { 68 | logger.error(`Error authenticating with Azure KeyVault. Ensure your managed identity or service principal has GET/LIST permissions. Error: ${err}`); 69 | throw err; 70 | } 71 | }; -------------------------------------------------------------------------------- /src/api/src/config/observability.ts: -------------------------------------------------------------------------------- 1 | import * as applicationInsights from "applicationinsights"; 2 | import { ObservabilityConfig } from "./appConfig"; 3 | import winston from "winston"; 4 | import { ApplicationInsightsTransport } from "./applicationInsightsTransport"; 5 | 6 | export enum LogLevel { 7 | Error = "error", 8 | Warning = "warn", 9 | Information = "info", 10 | Verbose = "verbose", 11 | Debug = "debug", 12 | } 13 | 14 | export const logger = winston.createLogger({ 15 | level: "info", 16 | format: winston.format.json(), 17 | transports: [ 18 | new winston.transports.File({ filename: "error.log", level: "error" }), 19 | ], 20 | exceptionHandlers: [ 21 | new winston.transports.File({ filename: "exceptions.log" }), 22 | ] 23 | }); 24 | 25 | export const observability = (config: ObservabilityConfig) => { 26 | // Append App Insights to the winston logger 27 | logger.defaultMeta = { 28 | app: config.roleName 29 | }; 30 | 31 | try { 32 | applicationInsights 33 | .setup(config.connectionString) 34 | .setAutoDependencyCorrelation(true) 35 | .setAutoCollectRequests(true) 36 | .setAutoCollectPerformance(true, true) 37 | .setAutoCollectExceptions(true) 38 | .setAutoCollectDependencies(true) 39 | .setAutoCollectConsole(true) 40 | .setUseDiskRetryCaching(true) 41 | .setSendLiveMetrics(true) 42 | .setDistributedTracingMode(applicationInsights.DistributedTracingModes.AI_AND_W3C); 43 | 44 | applicationInsights.defaultClient.context.tags[applicationInsights.defaultClient.context.keys.cloudRole] = config.roleName; 45 | applicationInsights.defaultClient.setAutoPopulateAzureProperties(true); 46 | applicationInsights.start(); 47 | 48 | const applicationInsightsTransport = new ApplicationInsightsTransport({ 49 | client: applicationInsights.defaultClient, 50 | level: LogLevel.Information, 51 | handleExceptions: true, // Handles node unhandled exceptions 52 | handleRejections: true, // Handles node promise rejections 53 | }); 54 | 55 | logger.add(applicationInsightsTransport); 56 | logger.info("Added ApplicationInsights logger transport"); 57 | } catch (err) { 58 | logger.error(`ApplicationInsights setup failed, ensure environment variable 'APPLICATIONINSIGHTS_CONNECTION_STRING' has been set. Error: ${err}`); 59 | } 60 | }; 61 | 62 | if (process.env.NODE_ENV !== "production") { 63 | logger.add(new winston.transports.Console({ 64 | format: winston.format.simple() 65 | })); 66 | } 67 | -------------------------------------------------------------------------------- /src/api/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "./app"; 2 | import { logger } from "./config/observability"; 3 | 4 | const main = async () => { 5 | const app = await createApp(); 6 | const port = process.env.FUNCTIONS_CUSTOMHANDLER_PORT || process.env.PORT || 3100; 7 | 8 | app.listen(port, () => { 9 | logger.info(`Started listening on port ${port}`); 10 | }); 11 | }; 12 | 13 | main(); -------------------------------------------------------------------------------- /src/api/src/models/mongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { DatabaseConfig } from "../config/appConfig"; 3 | import { logger } from "../config/observability"; 4 | 5 | export const configureMongoose = async (config: DatabaseConfig) => { 6 | mongoose.set("toJSON", { 7 | virtuals: true, 8 | versionKey: false, 9 | transform: (_, converted) => { 10 | converted.id = converted._id; 11 | delete converted._id; 12 | } 13 | }); 14 | 15 | try { 16 | const db = mongoose.connection; 17 | db.on("connecting", () => logger.info("Mongoose connecting...")); 18 | db.on("connected", () => logger.info("Mongoose connected successfully!")); 19 | db.on("disconnecting", () => logger.info("Mongoose disconnecting...")); 20 | db.on("disconnected", () => logger.info("Mongoose disconnected successfully!")); 21 | db.on("error", (err: Error) => logger.error("Mongoose database error:", err)); 22 | 23 | await mongoose.connect(config.connectionString, { dbName: config.databaseName }); 24 | } 25 | catch (err) { 26 | logger.error(`Mongoose database error: ${err}`); 27 | throw err; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/api/src/models/todoItem.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from "mongoose"; 2 | 3 | export enum TodoItemState { 4 | Todo = "todo", 5 | InProgress = "inprogress", 6 | Done = "done" 7 | } 8 | 9 | export type TodoItem = { 10 | id: mongoose.Types.ObjectId 11 | listId: mongoose.Types.ObjectId 12 | name: string 13 | state: TodoItemState 14 | description?: string 15 | dueDate?: Date 16 | completedDate?: Date 17 | createdDate?: Date 18 | updatedDate?: Date 19 | } 20 | 21 | const schema = new Schema({ 22 | listId: { 23 | type: Schema.Types.ObjectId, 24 | required: true 25 | }, 26 | name: { 27 | type: String, 28 | required: true 29 | }, 30 | description: String, 31 | state: { 32 | type: String, 33 | required: true, 34 | default: TodoItemState.Todo 35 | }, 36 | dueDate: Date, 37 | completedDate: Date, 38 | }, { 39 | timestamps: { 40 | createdAt: "createdDate", 41 | updatedAt: "updatedDate" 42 | } 43 | }); 44 | 45 | export const TodoItemModel = mongoose.model("TodoItem", schema, "TodoItem"); -------------------------------------------------------------------------------- /src/api/src/models/todoList.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from "mongoose"; 2 | 3 | export type TodoList = { 4 | id: string 5 | name: string 6 | description?: string 7 | createdDate?: Date 8 | updatedDate?: Date 9 | } 10 | 11 | const schema = new Schema({ 12 | name: { 13 | type: String, 14 | required: true, 15 | }, 16 | description: String 17 | }, { 18 | timestamps: { 19 | createdAt: "createdDate", 20 | updatedAt: "updatedDate" 21 | } 22 | }); 23 | 24 | export const TodoListModel = mongoose.model("TodoList", schema, "TodoList"); -------------------------------------------------------------------------------- /src/api/src/routes/common.ts: -------------------------------------------------------------------------------- 1 | export type PagingQueryParams = { 2 | top?: string 3 | skip?: string 4 | } -------------------------------------------------------------------------------- /src/api/src/routes/items.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import mongoose from "mongoose"; 3 | import { Request } from "express"; 4 | import { PagingQueryParams } from "../routes/common"; 5 | import { TodoItem, TodoItemModel, TodoItemState } from "../models/todoItem"; 6 | 7 | const router = express.Router({ mergeParams: true }); 8 | 9 | type TodoItemPathParams = { 10 | listId: mongoose.Types.ObjectId 11 | itemId: mongoose.Types.ObjectId 12 | state?: TodoItemState 13 | } 14 | 15 | /** 16 | * Gets a list of Todo item within a list 17 | */ 18 | router.get("/", async (req: Request, res) => { 19 | const query = TodoItemModel.find({ listId: req.params.listId }); 20 | const skip = req.query.skip ? parseInt(req.query.skip) : 0; 21 | const top = req.query.top ? parseInt(req.query.top) : 20; 22 | const lists = await query 23 | .skip(skip) 24 | .limit(top) 25 | .exec(); 26 | 27 | res.json(lists); 28 | }); 29 | 30 | /** 31 | * Creates a new Todo item within a list 32 | */ 33 | router.post("/", async (req: Request, res) => { 34 | try { 35 | const item: TodoItem = { 36 | ...req.body, 37 | listId: req.params.listId 38 | }; 39 | 40 | let newItem = new TodoItemModel(item); 41 | newItem = await newItem.save(); 42 | 43 | res.setHeader("location", `${req.protocol}://${req.get("Host")}/lists/${req.params.listId}/${newItem.id}`); 44 | res.status(201).json(newItem); 45 | } 46 | catch (err: any) { 47 | switch (err.constructor) { 48 | case mongoose.Error.CastError: 49 | case mongoose.Error.ValidationError: 50 | return res.status(400).json(err.errors); 51 | default: 52 | throw err; 53 | } 54 | } 55 | }); 56 | 57 | /** 58 | * Gets a Todo item with the specified ID within a list 59 | */ 60 | router.get("/:itemId", async (req: Request, res) => { 61 | try { 62 | const list = await TodoItemModel 63 | .findOne({ _id: req.params.itemId, listId: req.params.listId }) 64 | .orFail() 65 | .exec(); 66 | 67 | res.json(list); 68 | } 69 | catch (err: any) { 70 | switch (err.constructor) { 71 | case mongoose.Error.CastError: 72 | case mongoose.Error.DocumentNotFoundError: 73 | return res.status(404).send(); 74 | default: 75 | throw err; 76 | } 77 | } 78 | }); 79 | 80 | /** 81 | * Updates a Todo item with the specified ID within a list 82 | */ 83 | router.put("/:itemId", async (req: Request, res) => { 84 | try { 85 | const item: TodoItem = { 86 | ...req.body, 87 | id: req.params.itemId, 88 | listId: req.params.listId 89 | }; 90 | 91 | await TodoItemModel.validate(item); 92 | const updated = await TodoItemModel 93 | .findOneAndUpdate({ _id: item.id }, item, { new: true }) 94 | .orFail() 95 | .exec(); 96 | 97 | res.json(updated); 98 | } 99 | catch (err: any) { 100 | switch (err.constructor) { 101 | case mongoose.Error.ValidationError: 102 | return res.status(400).json(err.errors); 103 | case mongoose.Error.CastError: 104 | case mongoose.Error.DocumentNotFoundError: 105 | return res.status(404).send(); 106 | default: 107 | throw err; 108 | } 109 | } 110 | }); 111 | 112 | /** 113 | * Deletes a Todo item with the specified ID within a list 114 | */ 115 | router.delete("/:itemId", async (req, res) => { 116 | try { 117 | await TodoItemModel 118 | .findByIdAndDelete(req.params.itemId, {}) 119 | .orFail() 120 | .exec(); 121 | 122 | res.status(204).send(); 123 | } 124 | catch (err: any) { 125 | switch (err.constructor) { 126 | case mongoose.Error.CastError: 127 | case mongoose.Error.DocumentNotFoundError: 128 | return res.status(404).send(); 129 | default: 130 | throw err; 131 | } 132 | } 133 | }); 134 | 135 | /** 136 | * Get a list of items by state 137 | */ 138 | router.get("/state/:state", async (req: Request, res) => { 139 | const query = TodoItemModel.find({ listId: req.params.listId, state: req.params.state }); 140 | const skip = req.query.skip ? parseInt(req.query.skip) : 0; 141 | const top = req.query.top ? parseInt(req.query.top) : 20; 142 | 143 | const lists = await query 144 | .skip(skip) 145 | .limit(top) 146 | .exec(); 147 | 148 | res.json(lists); 149 | }); 150 | 151 | router.put("/state/:state", async (req: Request, res) => { 152 | try { 153 | const completedDate = req.params.state === TodoItemState.Done ? new Date() : undefined; 154 | 155 | const updateTasks = req.body.map( 156 | id => TodoItemModel 157 | .findOneAndUpdate( 158 | { listId: req.params.listId, _id: id }, 159 | { state: req.params.state, completedDate: completedDate }) 160 | .orFail() 161 | .exec() 162 | ); 163 | 164 | await Promise.all(updateTasks); 165 | 166 | res.status(204).send(); 167 | } 168 | catch (err: any) { 169 | switch (err.constructor) { 170 | case mongoose.Error.CastError: 171 | case mongoose.Error.DocumentNotFoundError: 172 | return res.status(404).send(); 173 | default: 174 | throw err; 175 | } 176 | } 177 | }); 178 | 179 | export default router; -------------------------------------------------------------------------------- /src/api/src/routes/lists.ts: -------------------------------------------------------------------------------- 1 | import express, { Request } from "express"; 2 | import mongoose from "mongoose"; 3 | import { PagingQueryParams } from "../routes/common"; 4 | import { TodoList, TodoListModel } from "../models/todoList"; 5 | 6 | const router = express.Router(); 7 | 8 | type TodoListPathParams = { 9 | listId: string 10 | } 11 | 12 | /** 13 | * Gets a list of Todo list 14 | */ 15 | router.get("/", async (req: Request, res) => { 16 | const query = TodoListModel.find(); 17 | const skip = req.query.skip ? parseInt(req.query.skip) : 0; 18 | const top = req.query.top ? parseInt(req.query.top) : 20; 19 | const lists = await query 20 | .skip(skip) 21 | .limit(top) 22 | .exec(); 23 | 24 | res.json(lists); 25 | }); 26 | 27 | /** 28 | * Creates a new Todo list 29 | */ 30 | router.post("/", async (req: Request, res) => { 31 | try { 32 | let list = new TodoListModel(req.body); 33 | list = await list.save(); 34 | 35 | res.setHeader("location", `${req.protocol}://${req.get("Host")}/lists/${list.id}`); 36 | res.status(201).json(list); 37 | } 38 | catch (err: any) { 39 | switch (err.constructor) { 40 | case mongoose.Error.CastError: 41 | case mongoose.Error.ValidationError: 42 | return res.status(400).json(err.errors); 43 | default: 44 | throw err; 45 | } 46 | } 47 | }); 48 | 49 | /** 50 | * Gets a Todo list with the specified ID 51 | */ 52 | router.get("/:listId", async (req: Request, res) => { 53 | try { 54 | const list = await TodoListModel 55 | .findById(req.params.listId) 56 | .orFail() 57 | .exec(); 58 | 59 | res.json(list); 60 | } 61 | catch (err: any) { 62 | switch (err.constructor) { 63 | case mongoose.Error.CastError: 64 | case mongoose.Error.DocumentNotFoundError: 65 | return res.status(404).send(); 66 | default: 67 | throw err; 68 | } 69 | } 70 | }); 71 | 72 | /** 73 | * Updates a Todo list with the specified ID 74 | */ 75 | router.put("/:listId", async (req: Request, res) => { 76 | try { 77 | const list: TodoList = { 78 | ...req.body, 79 | id: req.params.listId 80 | }; 81 | 82 | await TodoListModel.validate(list); 83 | const updated = await TodoListModel 84 | .findOneAndUpdate({ _id: list.id }, list, { new: true }) 85 | .orFail() 86 | .exec(); 87 | 88 | res.json(updated); 89 | } 90 | catch (err: any) { 91 | switch (err.constructor) { 92 | case mongoose.Error.ValidationError: 93 | return res.status(400).json(err.errors); 94 | case mongoose.Error.CastError: 95 | case mongoose.Error.DocumentNotFoundError: 96 | return res.status(404).send(); 97 | default: 98 | throw err; 99 | } 100 | } 101 | }); 102 | 103 | /** 104 | * Deletes a Todo list with the specified ID 105 | */ 106 | router.delete("/:listId", async (req: Request, res) => { 107 | try { 108 | await TodoListModel 109 | .findByIdAndDelete(req.params.listId, {}) 110 | .orFail() 111 | .exec(); 112 | 113 | res.status(204).send(); 114 | } 115 | catch (err: any) { 116 | switch (err.constructor) { 117 | case mongoose.Error.CastError: 118 | case mongoose.Error.DocumentNotFoundError: 119 | return res.status(404).send(); 120 | default: 121 | throw err; 122 | } 123 | } 124 | }); 125 | 126 | export default router; -------------------------------------------------------------------------------- /src/api/src/routes/routes.spec.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { Server } from "http"; 3 | import { Express } from "express"; 4 | import { createApp } from "../app"; 5 | import { TodoItem, TodoItemState } from "../models/todoItem"; 6 | import { TodoList } from "../models/todoList"; 7 | 8 | describe("API", () => { 9 | let app: Express; 10 | let server: Server; 11 | 12 | beforeAll(async () => { 13 | app = await createApp(); 14 | const port = process.env.PORT || 3100; 15 | 16 | server = app.listen(port, () => { 17 | console.log(`Started listening on port ${port}`); 18 | }); 19 | }); 20 | 21 | afterAll((done) => { 22 | server.close(done); 23 | console.log("Stopped server"); 24 | }); 25 | 26 | describe("Todo List Routes", () => { 27 | it("can GET an array of lists", async () => { 28 | const todoList: Partial = { 29 | name: "GET all test", 30 | description: "GET all description" 31 | }; 32 | 33 | let res = await createList(todoList); 34 | const newList = res.body as TodoList; 35 | 36 | res = await getLists(); 37 | 38 | expect(res.statusCode).toEqual(200); 39 | expect(res.body.length).toBeGreaterThan(0); 40 | 41 | await deleteList(newList.id); 42 | }); 43 | 44 | it("can GET an array of lists with paging", async () => { 45 | const todoList: Partial = { 46 | name: "GET paging test", 47 | description: "GET paging description" 48 | }; 49 | 50 | let res = await createList(todoList); 51 | const list1 = res.body as TodoList; 52 | res = await createList(todoList); 53 | const list2 = res.body as TodoList; 54 | 55 | res = await getLists("top=1&skip=1"); 56 | 57 | expect(res.statusCode).toEqual(200); 58 | expect(res.body).toHaveLength(1); 59 | 60 | await deleteList(list1.id); 61 | await deleteList(list2.id); 62 | }); 63 | 64 | it("can GET a list by unique id", async () => { 65 | const todoList: Partial = { 66 | name: "GET by id test", 67 | description: "GET by id description" 68 | }; 69 | 70 | let res = await createList(todoList); 71 | const newList = res.body as TodoList; 72 | res = await getList(res.body.id); 73 | 74 | expect(res.statusCode).toEqual(200); 75 | expect(res.body).toMatchObject(newList); 76 | 77 | await deleteList(newList.id); 78 | }); 79 | 80 | it("can POST (create) new list", async () => { 81 | const todoList: Partial = { 82 | name: "POST test", 83 | description: "POST description" 84 | }; 85 | 86 | const res = await createList(todoList); 87 | 88 | expect(res.statusCode).toEqual(201); 89 | expect(res.body).toMatchObject({ 90 | ...todoList, 91 | id: expect.any(String), 92 | createdDate: expect.any(String), 93 | updatedDate: expect.any(String) 94 | }); 95 | 96 | await deleteList(res.body.id); 97 | }); 98 | 99 | it("can PUT (update) lists", async () => { 100 | const todoList: Partial = { 101 | name: "PUT test", 102 | description: "PUT description" 103 | }; 104 | 105 | let res = await createList(todoList); 106 | const listToUpdate: Partial = { 107 | ...res.body, 108 | name: "PUT test (updated)" 109 | }; 110 | 111 | res = await updateList(res.body.id, listToUpdate); 112 | 113 | expect(res.statusCode).toEqual(200); 114 | expect(res.body).toMatchObject({ 115 | id: listToUpdate.id, 116 | name: listToUpdate.name 117 | }); 118 | 119 | await deleteList(res.body.id); 120 | }); 121 | 122 | it("can DELETE lists", async () => { 123 | const todoList: Partial = { 124 | name: "PUT test", 125 | description: "PUT description" 126 | }; 127 | 128 | let res = await createList(todoList); 129 | const newList = res.body as TodoList; 130 | res = await deleteList(newList.id); 131 | 132 | expect(res.statusCode).toEqual(204); 133 | 134 | res = await getList(newList.id); 135 | expect(res.statusCode).toEqual(404); 136 | }); 137 | }); 138 | 139 | describe("Todo Item Routes", () => { 140 | let testList: TodoList; 141 | 142 | beforeAll(async () => { 143 | const res = await createList({ name: "Integration test" }); 144 | testList = res.body as TodoList; 145 | }); 146 | 147 | afterAll(async () => { 148 | await deleteList(testList.id); 149 | }); 150 | 151 | it("can GET an array of items", async () => { 152 | const todoItem: Partial = { 153 | name: "GET all test", 154 | description: "GET all description" 155 | }; 156 | 157 | let res = await createItem(testList.id, todoItem); 158 | const newItem = res.body as TodoItem; 159 | 160 | res = await getItems(testList.id); 161 | 162 | expect(res.statusCode).toEqual(200); 163 | expect(res.body).toHaveLength(1); 164 | 165 | await deleteItem(newItem.listId.toString(), newItem.id.toString()); 166 | }); 167 | 168 | it("can GET an array of items with paging", async () => { 169 | const todoItem: Partial = { 170 | name: "GET paging test", 171 | description: "GET paging description" 172 | }; 173 | 174 | let res = await createItem(testList.id, todoItem); 175 | const item1 = res.body as TodoItem; 176 | res = await createItem(testList.id, todoItem); 177 | const item2 = res.body as TodoItem; 178 | 179 | res = await getItems(testList.id, "top=1&skip=1"); 180 | 181 | expect(res.statusCode).toEqual(200); 182 | expect(res.body).toHaveLength(1); 183 | 184 | await deleteItem(item1.listId.toString(), item1.id.toString()); 185 | await deleteItem(item2.listId.toString(), item2.id.toString()); 186 | }); 187 | 188 | it("can GET an array of items by state", async () => { 189 | const item1: Partial = { 190 | name: "GET state test (todo)", 191 | description: "GET paging description", 192 | state: TodoItemState.Todo 193 | }; 194 | 195 | const item2: Partial = { 196 | name: "GET state test (inprogress)", 197 | description: "GET paging description", 198 | state: TodoItemState.InProgress 199 | }; 200 | 201 | let res = await createItem(testList.id, item1); 202 | const newItem1 = res.body as TodoItem; 203 | res = await createItem(testList.id, item2); 204 | const newItem2 = res.body as TodoItem; 205 | 206 | res = await getItems(testList.id, "", TodoItemState.Todo); 207 | 208 | expect(res.statusCode).toEqual(200); 209 | expect(res.body.length).toEqual(1); // Expect only 1 item to be in the TODO state. 210 | 211 | await deleteItem(newItem1.listId.toString(), newItem1.id.toString()); 212 | await deleteItem(newItem2.listId.toString(), newItem2.id.toString()); 213 | }); 214 | 215 | it("can GET an item by unique id", async () => { 216 | const todoItem: Partial = { 217 | name: "GET by id test", 218 | description: "GET by id description" 219 | }; 220 | 221 | let res = await createItem(testList.id, todoItem); 222 | const newItem = res.body as TodoItem; 223 | res = await getItem(newItem.listId.toString(), newItem.id.toString()); 224 | 225 | expect(res.statusCode).toEqual(200); 226 | expect(res.body).toMatchObject(newItem); 227 | 228 | await deleteItem(newItem.listId.toString(), newItem.id.toString()); 229 | }); 230 | 231 | it("can POST (create) new item", async () => { 232 | const todoItem: Partial = { 233 | name: "POST test", 234 | description: "POST description", 235 | state: TodoItemState.Todo 236 | }; 237 | 238 | const res = await createItem(testList.id, todoItem); 239 | 240 | expect(res.statusCode).toEqual(201); 241 | expect(res.body).toMatchObject({ 242 | ...todoItem, 243 | id: expect.any(String), 244 | createdDate: expect.any(String), 245 | updatedDate: expect.any(String) 246 | }); 247 | 248 | await deleteItem(res.body.listId, res.body.id); 249 | }); 250 | 251 | it("can PUT (update) items", async () => { 252 | const todoItem: Partial = { 253 | name: "PUT test", 254 | description: "PUT description" 255 | }; 256 | 257 | let res = await createItem(testList.id, todoItem); 258 | const itemToUpdate: TodoItem = { 259 | ...res.body, 260 | name: "PUT test (updated)", 261 | state: TodoItemState.InProgress 262 | }; 263 | 264 | res = await updateItem(itemToUpdate.listId.toString(), itemToUpdate.id.toString(), itemToUpdate); 265 | 266 | expect(res.statusCode).toEqual(200); 267 | expect(res.body).toMatchObject({ 268 | id: itemToUpdate.id, 269 | name: itemToUpdate.name, 270 | state: itemToUpdate.state 271 | }); 272 | 273 | await deleteItem(res.body.listId, res.body.id); 274 | }); 275 | 276 | it("can DELETE items", async () => { 277 | const todoItem: Partial = { 278 | name: "PUT test", 279 | description: "PUT description" 280 | }; 281 | 282 | let res = await createItem(testList.id, todoItem); 283 | const newItem = res.body as TodoItem; 284 | res = await deleteItem(newItem.listId.toString(), newItem.id.toString()); 285 | 286 | expect(res.statusCode).toEqual(204); 287 | 288 | res = await getItem(newItem.listId.toString(), newItem.id.toString()); 289 | expect(res.statusCode).toEqual(404); 290 | }); 291 | }); 292 | 293 | const getLists = (query = "") => { 294 | return request(app) 295 | .get("/lists") 296 | .query(query) 297 | .send(); 298 | }; 299 | 300 | const getList = (listId: string) => { 301 | return request(app) 302 | .get(`/lists/${listId}`) 303 | .send(); 304 | }; 305 | 306 | const createList = (list: Partial) => { 307 | return request(app) 308 | .post("/lists") 309 | .send(list); 310 | }; 311 | 312 | const updateList = (listId: string, list: Partial) => { 313 | return request(app) 314 | .put(`/lists/${listId}`) 315 | .send(list); 316 | }; 317 | 318 | const deleteList = (listId: string) => { 319 | return request(app) 320 | .delete(`/lists/${listId}`) 321 | .send(); 322 | }; 323 | 324 | const getItems = (listId: string, query = "", state?: TodoItemState) => { 325 | const path = state 326 | ? `/lists/${listId}/items/state/${state.toString()}` 327 | : `/lists/${listId}/items`; 328 | 329 | return request(app) 330 | .get(path) 331 | .query(query) 332 | .send(); 333 | }; 334 | 335 | const getItem = (listId: string, itemId: string) => { 336 | return request(app) 337 | .get(`/lists/${listId}/items/${itemId}`) 338 | .send(); 339 | }; 340 | 341 | const createItem = (listId: string, item: Partial) => { 342 | return request(app) 343 | .post(`/lists/${listId}/items`) 344 | .send(item); 345 | }; 346 | 347 | const updateItem = (listId: string, itemId: string, item: Partial) => { 348 | return request(app) 349 | .put(`/lists/${listId}/items/${itemId}`) 350 | .send(item); 351 | }; 352 | 353 | const deleteItem = (listId: string, itemId: string) => { 354 | return request(app) 355 | .delete(`/lists/${listId}/items/${itemId}`) 356 | .send(); 357 | }; 358 | }); 359 | -------------------------------------------------------------------------------- /src/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs", /* Specify what module code is generated. */ 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./dist", 51 | "rootDir": "./src", 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | 68 | /* Interop Constraints */ 69 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 70 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 71 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 72 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 73 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 74 | 75 | /* Type Checking */ 76 | "strict": true, /* Enable all strict type-checking options. */ 77 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 78 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 79 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 80 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 81 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 82 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 83 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 84 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 85 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 86 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 87 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 88 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 89 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 90 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 91 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 92 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 93 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 94 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 95 | 96 | /* Completeness */ 97 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 98 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/web/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.devcontainer 2 | **/build 3 | **/node_modules 4 | README.md 5 | -------------------------------------------------------------------------------- /src/web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .eslintcache 25 | 26 | env-config.js -------------------------------------------------------------------------------- /src/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS build 2 | 3 | # make the 'app' folder the current working directory 4 | WORKDIR /app 5 | 6 | COPY . . 7 | 8 | # install project dependencies 9 | RUN npm ci 10 | RUN npm run build 11 | 12 | FROM nginx:alpine 13 | 14 | WORKDIR /usr/share/nginx/html 15 | COPY --from=build /app/dist . 16 | COPY --from=build /app/nginx/nginx.conf /etc/nginx/conf.d/default.conf 17 | 18 | EXPOSE 80 19 | 20 | CMD ["/bin/sh", "-c", "nginx -g \"daemon off;\""] 21 | -------------------------------------------------------------------------------- /src/web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App and Fluent UI 2 | 3 | This is a [Create React App](https://github.com/facebook/create-react-app) based repo that comes with Fluent UI pre-installed! 4 | 5 | ## Setup 6 | 7 | Create a `.env` file within the base of the `reactd-fluent` folder with the following configuration: 8 | 9 | - `VITE_API_BASE_URL` - Base URL for all api requests, (ex: `http://localhost:3100`) 10 | 11 | > Note: The URL must include the schema, either `http://` or `https://`. 12 | 13 | - `VITE_APPLICATIONINSIGHTS_CONNECTION_STRING` - Azure Application Insights connection string 14 | 15 | ## Available Scripts 16 | 17 | In the project directory, you can run: 18 | 19 | ### `npm ci` 20 | 21 | Installs local pre-requisites. 22 | 23 | ### `npm start` 24 | 25 | Runs the app in the development mode. 26 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 27 | 28 | The page will reload if you make edits. 29 | You will also see any lint errors in the console. 30 | 31 | ### `npm test` 32 | 33 | Launches the test runner in the interactive watch mode. 34 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 35 | 36 | ### `npm run build` 37 | 38 | Builds the app for production to the `build` folder. 39 | It correctly bundles React in production mode and optimizes the build for the best performance. 40 | 41 | The build is minified and the filenames include the hashes. 42 | Your app is ready to be deployed! 43 | 44 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 45 | 46 | ### `npm run eject` 47 | 48 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 49 | 50 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 51 | 52 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 53 | 54 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 55 | 56 | ## Learn More 57 | 58 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 59 | 60 | To learn React, check out the [React documentation](https://reactjs.org/). 61 | 62 | ## Contributing 63 | 64 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 65 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 66 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 67 | 68 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 69 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 70 | provided by the bot. You will only need to do this once across all repos using our CLA. 71 | 72 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 73 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 74 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 75 | -------------------------------------------------------------------------------- /src/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 23 | 24 | AzDev Todo 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/web/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | location / { 4 | root /usr/share/nginx/html; 5 | index index.html index.htm; 6 | try_files $uri $uri/ /index.html =404; 7 | } 8 | } -------------------------------------------------------------------------------- /src/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 14 | "@fluentui/react": "^8.73.0", 15 | "@microsoft/applicationinsights-react-js": "^17.0.3", 16 | "@microsoft/applicationinsights-web": "^3.0.7", 17 | "axios": "^1.8.2", 18 | "history": "^5.3.0", 19 | "dotenv": "^16.3.1", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-router-dom": "^7.5.2", 23 | "web-vitals": "^3.5.1" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^18.2.43", 27 | "@types/react-dom": "^18.2.17", 28 | "@typescript-eslint/eslint-plugin": "^6.14.0", 29 | "@typescript-eslint/parser": "^6.14.0", 30 | "@vitejs/plugin-react-swc": "^3.8.0", 31 | "eslint": "^8.55.0", 32 | "eslint-plugin-react-hooks": "^4.6.0", 33 | "eslint-plugin-react-refresh": "^0.4.5", 34 | "typescript": "^5.2.2", 35 | "vite": "^6.3.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo/1a5662ba27396ff4bac0300f101a3bd836233177/src/web/public/favicon.ico -------------------------------------------------------------------------------- /src/web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#0392ff", 14 | "background_color": "#fcfcfc" 15 | } 16 | -------------------------------------------------------------------------------- /src/web/src/@types/window.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_API_BASE_URL: string; 5 | readonly VITE_APPLICATIONINSIGHTS_CONNECTION_STRING: string; 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | -------------------------------------------------------------------------------- /src/web/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | height: 100vh; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, FC } from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import Layout from './layout/layout'; 4 | import './App.css'; 5 | import { DarkTheme } from './ux/theme'; 6 | import { AppContext, ApplicationState, getDefaultState } from './models/applicationState'; 7 | import appReducer from './reducers'; 8 | import { TodoContext } from './components/todoContext'; 9 | import { initializeIcons } from '@fluentui/react/lib/Icons'; 10 | import { ThemeProvider } from '@fluentui/react'; 11 | import Telemetry from './components/telemetry'; 12 | 13 | initializeIcons(undefined, { disableWarnings: true }); 14 | 15 | const App: FC = () => { 16 | const defaultState: ApplicationState = getDefaultState(); 17 | const [applicationState, dispatch] = useReducer(appReducer, defaultState); 18 | const initialContext: AppContext = { state: applicationState, dispatch: dispatch } 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /src/web/src/actions/actionCreators.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Dispatch } from "react"; 3 | 4 | export interface Action { 5 | type: T 6 | } 7 | 8 | export interface AnyAction extends Action { 9 | [extraProps: string]: any 10 | } 11 | 12 | export interface ActionCreator { 13 | (...args: P): A 14 | } 15 | 16 | export interface ActionCreatorsMapObject { 17 | [key: string]: ActionCreator 18 | } 19 | 20 | export type ActionMethod = (dispatch: Dispatch) => Promise; 21 | 22 | export interface PayloadAction extends Action { 23 | payload: TPayload; 24 | } 25 | 26 | export function createAction>(type: TAction["type"]): () => Action { 27 | return () => ({ 28 | type, 29 | }); 30 | } 31 | 32 | export function createPayloadAction>(type: TAction["type"]): (payload: TAction["payload"]) => PayloadAction { 33 | return (payload: TAction["payload"]) => ({ 34 | type, 35 | payload, 36 | }); 37 | } 38 | 39 | export type BoundActionMethod = (...args: A[]) => Promise; 40 | export type BoundActionsMapObject = { [key: string]: BoundActionMethod } 41 | 42 | function bindActionCreator(actionCreator: ActionCreator, dispatch: Dispatch): BoundActionMethod { 43 | return async function (this: any, ...args: any[]) { 44 | const actionMethod = actionCreator.apply(this, args) as any as ActionMethod; 45 | return await actionMethod(dispatch); 46 | } 47 | } 48 | 49 | export function bindActionCreators( 50 | actionCreators: ActionCreator | ActionCreatorsMapObject, 51 | dispatch: Dispatch 52 | ): BoundActionsMapObject | BoundActionMethod { 53 | if (typeof actionCreators === 'function') { 54 | return bindActionCreator(actionCreators, dispatch) 55 | } 56 | 57 | if (typeof actionCreators !== 'object' || actionCreators === null) { 58 | throw new Error('bindActionCreators expected an object or a function, did you write "import ActionCreators from" instead of "import * as ActionCreators from"?') 59 | } 60 | 61 | const boundActionCreators: ActionCreatorsMapObject = {} 62 | for (const key in actionCreators) { 63 | const actionCreator = actionCreators[key] 64 | if (typeof actionCreator === 'function') { 65 | boundActionCreators[key] = bindActionCreator(actionCreator, dispatch) 66 | } 67 | } 68 | return boundActionCreators 69 | } -------------------------------------------------------------------------------- /src/web/src/actions/common.ts: -------------------------------------------------------------------------------- 1 | import * as itemActions from './itemActions'; 2 | import * as listActions from './listActions'; 3 | 4 | export enum ActionTypes { 5 | LOAD_TODO_LISTS = "LOAD_TODO_LISTS", 6 | LOAD_TODO_LIST = "LOAD_TODO_LIST", 7 | SELECT_TODO_LIST = "SELECT_TODO_LIST", 8 | SAVE_TODO_LIST = "SAVE_TODO_LIST", 9 | DELETE_TODO_LIST = "DELETE_TODO_LIST", 10 | LOAD_TODO_ITEMS = "LOAD_TODO_ITEMS", 11 | LOAD_TODO_ITEM = "LOAD_TODO_ITEM", 12 | SELECT_TODO_ITEM = "SELECT_TODO_ITEM", 13 | SAVE_TODO_ITEM = "SAVE_TODO_ITEM", 14 | DELETE_TODO_ITEM = "DELETE_TODO_ITEM" 15 | } 16 | 17 | export type TodoActions = 18 | itemActions.ListItemsAction | 19 | itemActions.SelectItemAction | 20 | itemActions.LoadItemAction | 21 | itemActions.SaveItemAction | 22 | itemActions.DeleteItemAction | 23 | listActions.ListListsAction | 24 | listActions.SelectListAction | 25 | listActions.LoadListAction | 26 | listActions.SaveListAction | 27 | listActions.DeleteListAction; -------------------------------------------------------------------------------- /src/web/src/actions/itemActions.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from "react"; 2 | import { TodoItem } from "../models"; 3 | import { ItemService } from "../services/itemService"; 4 | import { ActionTypes } from "./common"; 5 | import config from "../config" 6 | import { ActionMethod, createPayloadAction, PayloadAction } from "./actionCreators"; 7 | 8 | export interface QueryOptions { 9 | [key: string]: RegExp | boolean 10 | } 11 | 12 | export interface ItemActions { 13 | list(listId: string, options?: QueryOptions): Promise 14 | select(item?: TodoItem): Promise 15 | load(listId: string, id: string): Promise 16 | save(listId: string, Item: TodoItem): Promise 17 | remove(listId: string, Item: TodoItem): Promise 18 | } 19 | 20 | export const list = (listId: string, options?: QueryOptions): ActionMethod => async (dispatch: Dispatch) => { 21 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 22 | const items = await itemService.getList(options); 23 | 24 | dispatch(listItemsAction(items)); 25 | 26 | return items; 27 | } 28 | 29 | export const select = (item?: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 30 | dispatch(selectItemAction(item)); 31 | 32 | return Promise.resolve(item); 33 | } 34 | 35 | export const load = (listId: string, id: string): ActionMethod => async (dispatch: Dispatch) => { 36 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 37 | const item = await itemService.get(id); 38 | 39 | dispatch(loadItemAction(item)); 40 | 41 | return item; 42 | } 43 | 44 | export const save = (listId: string, item: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 45 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 46 | const newItem = await itemService.save(item); 47 | 48 | dispatch(saveItemAction(newItem)); 49 | 50 | return newItem; 51 | } 52 | 53 | export const remove = (listId: string, item: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 54 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 55 | if (item.id) { 56 | await itemService.delete(item.id); 57 | dispatch(deleteItemAction(item.id)); 58 | } 59 | } 60 | 61 | export interface ListItemsAction extends PayloadAction { 62 | type: ActionTypes.LOAD_TODO_ITEMS 63 | } 64 | 65 | export interface SelectItemAction extends PayloadAction { 66 | type: ActionTypes.SELECT_TODO_ITEM 67 | } 68 | 69 | export interface LoadItemAction extends PayloadAction { 70 | type: ActionTypes.LOAD_TODO_ITEM 71 | } 72 | 73 | export interface SaveItemAction extends PayloadAction { 74 | type: ActionTypes.SAVE_TODO_ITEM 75 | } 76 | 77 | export interface DeleteItemAction extends PayloadAction { 78 | type: ActionTypes.DELETE_TODO_ITEM 79 | } 80 | 81 | const listItemsAction = createPayloadAction(ActionTypes.LOAD_TODO_ITEMS); 82 | const selectItemAction = createPayloadAction(ActionTypes.SELECT_TODO_ITEM); 83 | const loadItemAction = createPayloadAction(ActionTypes.LOAD_TODO_ITEM); 84 | const saveItemAction = createPayloadAction(ActionTypes.SAVE_TODO_ITEM); 85 | const deleteItemAction = createPayloadAction(ActionTypes.DELETE_TODO_ITEM); 86 | -------------------------------------------------------------------------------- /src/web/src/actions/listActions.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from "react"; 2 | import { TodoList } from "../models"; 3 | import { ListService } from "../services/listService"; 4 | import { ActionTypes } from "./common"; 5 | import config from "../config" 6 | import { trackEvent } from "../services/telemetryService"; 7 | import { ActionMethod, createPayloadAction, PayloadAction } from "./actionCreators"; 8 | import { QueryOptions } from "./itemActions"; 9 | 10 | const listService = new ListService(config.api.baseUrl, '/lists'); 11 | 12 | export interface ListActions { 13 | list(options?: QueryOptions): Promise 14 | load(id: string): Promise 15 | select(list: TodoList): Promise 16 | save(list: TodoList): Promise 17 | remove(id: string): Promise 18 | } 19 | 20 | export const list = (options?: QueryOptions): ActionMethod => async (dispatch: Dispatch) => { 21 | const lists = await listService.getList(options); 22 | 23 | dispatch(listListsAction(lists)); 24 | 25 | return lists; 26 | } 27 | 28 | export const select = (list: TodoList): ActionMethod => (dispatch: Dispatch) => { 29 | dispatch(selectListAction(list)); 30 | 31 | return Promise.resolve(list); 32 | } 33 | 34 | export const load = (id: string): ActionMethod => async (dispatch: Dispatch) => { 35 | const list = await listService.get(id); 36 | 37 | dispatch(loadListAction(list)); 38 | 39 | return list; 40 | } 41 | 42 | export const save = (list: TodoList): ActionMethod => async (dispatch: Dispatch) => { 43 | const newList = await listService.save(list); 44 | 45 | dispatch(saveListAction(newList)); 46 | 47 | trackEvent(ActionTypes.SAVE_TODO_LIST.toString()); 48 | 49 | return newList; 50 | } 51 | 52 | export const remove = (id: string): ActionMethod => async (dispatch: Dispatch) => { 53 | await listService.delete(id); 54 | 55 | dispatch(deleteListAction(id)); 56 | } 57 | 58 | export interface ListListsAction extends PayloadAction { 59 | type: ActionTypes.LOAD_TODO_LISTS 60 | } 61 | 62 | export interface SelectListAction extends PayloadAction { 63 | type: ActionTypes.SELECT_TODO_LIST 64 | } 65 | 66 | export interface LoadListAction extends PayloadAction { 67 | type: ActionTypes.LOAD_TODO_LIST 68 | } 69 | 70 | export interface SaveListAction extends PayloadAction { 71 | type: ActionTypes.SAVE_TODO_LIST 72 | } 73 | 74 | export interface DeleteListAction extends PayloadAction { 75 | type: ActionTypes.DELETE_TODO_LIST 76 | } 77 | 78 | const listListsAction = createPayloadAction(ActionTypes.LOAD_TODO_LISTS); 79 | const selectListAction = createPayloadAction(ActionTypes.SELECT_TODO_LIST); 80 | const loadListAction = createPayloadAction(ActionTypes.LOAD_TODO_LIST); 81 | const saveListAction = createPayloadAction(ActionTypes.SAVE_TODO_LIST); 82 | const deleteListAction = createPayloadAction(ActionTypes.DELETE_TODO_LIST); 83 | -------------------------------------------------------------------------------- /src/web/src/components/telemetry.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement, useEffect, PropsWithChildren } from 'react'; 2 | import { TelemetryProvider } from './telemetryContext'; 3 | import { reactPlugin, getApplicationInsights } from '../services/telemetryService'; 4 | 5 | type TelemetryProps = PropsWithChildren; 6 | 7 | const Telemetry: FC = (props: TelemetryProps): ReactElement => { 8 | 9 | useEffect(() => { 10 | getApplicationInsights(); 11 | }, []); 12 | 13 | return ( 14 | 15 | {props.children} 16 | 17 | ); 18 | } 19 | 20 | export default Telemetry; 21 | -------------------------------------------------------------------------------- /src/web/src/components/telemetryContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { reactPlugin } from '../services/telemetryService'; 3 | 4 | const TelemetryContext = createContext(reactPlugin); 5 | 6 | export const TelemetryProvider = TelemetryContext.Provider; 7 | export const TelemetryConsumer = TelemetryContext.Consumer; 8 | export default TelemetryContext; 9 | -------------------------------------------------------------------------------- /src/web/src/components/telemetryWithAppInsights.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, ComponentClass } from 'react'; 2 | import { reactPlugin } from '../services/telemetryService'; 3 | import { withAITracking } from '@microsoft/applicationinsights-react-js'; 4 | 5 | 6 | const withApplicationInsights = (component: ComponentType, componentName: string): ComponentClass, unknown> => withAITracking(reactPlugin, component, componentName); 7 | 8 | export default withApplicationInsights; 9 | -------------------------------------------------------------------------------- /src/web/src/components/todoContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { AppContext, getDefaultState } from "../models/applicationState"; 3 | 4 | const initialState = getDefaultState(); 5 | const dispatch = () => { return }; 6 | 7 | export const TodoContext = createContext({ state: initialState, dispatch: dispatch }); -------------------------------------------------------------------------------- /src/web/src/components/todoItemDetailPane.tsx: -------------------------------------------------------------------------------- 1 | import { Text, DatePicker, Stack, TextField, PrimaryButton, DefaultButton, Dropdown, IDropdownOption, FontIcon } from '@fluentui/react'; 2 | import { useEffect, useState, FC, ReactElement, MouseEvent, FormEvent } from 'react'; 3 | import { TodoItem, TodoItemState } from '../models'; 4 | import { stackGaps, stackItemMargin, stackItemPadding, titleStackStyles } from '../ux/styles'; 5 | 6 | interface TodoItemDetailPaneProps { 7 | item?: TodoItem; 8 | onEdit: (item: TodoItem) => void 9 | onCancel: () => void 10 | } 11 | 12 | export const TodoItemDetailPane: FC = (props: TodoItemDetailPaneProps): ReactElement => { 13 | const [name, setName] = useState(props.item?.name || ''); 14 | const [description, setDescription] = useState(props.item?.description); 15 | const [dueDate, setDueDate] = useState(props.item?.dueDate); 16 | const [state, setState] = useState(props.item?.state || TodoItemState.Todo); 17 | 18 | useEffect(() => { 19 | setName(props.item?.name || ''); 20 | setDescription(props.item?.description); 21 | setDueDate(props.item?.dueDate ? new Date(props.item?.dueDate) : undefined); 22 | setState(props.item?.state || TodoItemState.Todo); 23 | }, [props.item]); 24 | 25 | const saveTodoItem = (evt: MouseEvent) => { 26 | evt.preventDefault(); 27 | 28 | if (!props.item?.id) { 29 | return; 30 | } 31 | 32 | const todoItem: TodoItem = { 33 | id: props.item.id, 34 | listId: props.item.listId, 35 | name: name, 36 | description: description, 37 | dueDate: dueDate, 38 | state: state, 39 | }; 40 | 41 | props.onEdit(todoItem); 42 | }; 43 | 44 | const cancelEdit = () => { 45 | props.onCancel(); 46 | } 47 | 48 | const onStateChange = (_evt: FormEvent, value?: IDropdownOption) => { 49 | if (value) { 50 | setState(value.key as TodoItemState); 51 | } 52 | } 53 | 54 | const onDueDateChange = (date: Date | null | undefined) => { 55 | setDueDate(date || undefined); 56 | } 57 | 58 | const todoStateOptions: IDropdownOption[] = [ 59 | { key: TodoItemState.Todo, text: 'To Do' }, 60 | { key: TodoItemState.InProgress, text: 'In Progress' }, 61 | { key: TodoItemState.Done, text: 'Done' }, 62 | ]; 63 | 64 | return ( 65 | 66 | {props.item && 67 | <> 68 | 69 | {name} 70 | {description} 71 | 72 | 73 | setName(value || '')} /> 74 | setDescription(value)} /> 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | } 86 | {!props.item && 87 | 88 | 89 | Select an item to edit 90 | } 91 | 92 | ); 93 | } 94 | 95 | export default TodoItemDetailPane; -------------------------------------------------------------------------------- /src/web/src/components/todoItemListPane.tsx: -------------------------------------------------------------------------------- 1 | import { CommandBar, DetailsList, DetailsListLayoutMode, IStackStyles, Selection, Label, Spinner, SpinnerSize, Stack, IIconProps, SearchBox, Text, IGroup, IColumn, MarqueeSelection, FontIcon, IObjectWithKey, CheckboxVisibility, IDetailsGroupRenderProps, getTheme } from '@fluentui/react'; 2 | import { ReactElement, useEffect, useState, FormEvent, FC } from 'react'; 3 | import { useNavigate } from 'react-router'; 4 | import { TodoItem, TodoItemState, TodoList } from '../models'; 5 | import { stackItemPadding } from '../ux/styles'; 6 | 7 | interface TodoItemListPaneProps { 8 | list?: TodoList 9 | items?: TodoItem[] 10 | selectedItem?: TodoItem; 11 | disabled: boolean 12 | onCreated: (item: TodoItem) => void 13 | onDelete: (item: TodoItem) => void 14 | onComplete: (item: TodoItem) => void 15 | onSelect: (item?: TodoItem) => void 16 | } 17 | 18 | interface TodoDisplayItem extends IObjectWithKey { 19 | id?: string 20 | listId: string 21 | name: string 22 | state: TodoItemState 23 | description?: string 24 | dueDate: Date | string 25 | completedDate: Date | string 26 | data: TodoItem 27 | createdDate?: Date 28 | updatedDate?: Date 29 | } 30 | 31 | const addIconProps: IIconProps = { 32 | iconName: 'Add', 33 | styles: { 34 | root: { 35 | } 36 | } 37 | }; 38 | 39 | const createListItems = (items: TodoItem[]): TodoDisplayItem[] => { 40 | return items.map(item => ({ 41 | ...item, 42 | key: item.id, 43 | dueDate: item.dueDate ? new Date(item.dueDate).toDateString() : 'None', 44 | completedDate: item.completedDate ? new Date(item.completedDate).toDateString() : 'N/A', 45 | data: item 46 | })); 47 | }; 48 | 49 | const stackStyles: IStackStyles = { 50 | root: { 51 | alignItems: 'center' 52 | } 53 | } 54 | 55 | const TodoItemListPane: FC = (props: TodoItemListPaneProps): ReactElement => { 56 | const theme = getTheme(); 57 | const navigate = useNavigate(); 58 | const [newItemName, setNewItemName] = useState(''); 59 | const [items, setItems] = useState(createListItems(props.items || [])); 60 | const [selectedItems, setSelectedItems] = useState([]); 61 | const [isDoneCategoryCollapsed, setIsDoneCategoryCollapsed] = useState(true); 62 | 63 | // eslint-disable-next-line react-hooks/exhaustive-deps 64 | const selection = new Selection({ 65 | onSelectionChanged: () => { 66 | const selectedItems = selection.getSelection().map(item => (item as TodoDisplayItem).data); 67 | setSelectedItems(selectedItems); 68 | } 69 | }); 70 | 71 | // Handle list changed 72 | useEffect(() => { 73 | setIsDoneCategoryCollapsed(true); 74 | setSelectedItems([]); 75 | }, [props.list]); 76 | 77 | // Handle items changed 78 | useEffect(() => { 79 | const sortedItems = (props.items || []).sort((a, b) => { 80 | if (a.state === b.state) { 81 | return a.name < b.name ? -1 : 1; 82 | } 83 | 84 | return a.state < b.state ? -1 : 1; 85 | }) 86 | setItems(createListItems(sortedItems || [])); 87 | }, [props.items]); 88 | 89 | // Handle selected item changed 90 | useEffect(() => { 91 | if (items.length > 0 && props.selectedItem?.id) { 92 | selection.setKeySelected(props.selectedItem.id, true, true); 93 | } 94 | 95 | const doneItems = selectedItems.filter(i => i.state === TodoItemState.Done); 96 | if (doneItems.length > 0) { 97 | setIsDoneCategoryCollapsed(false); 98 | } 99 | 100 | }, [items.length, props.selectedItem, selectedItems, selection]) 101 | 102 | const groups: IGroup[] = [ 103 | { 104 | key: TodoItemState.Todo, 105 | name: 'Todo', 106 | count: items.filter(i => i.state === TodoItemState.Todo).length, 107 | startIndex: items.findIndex(i => i.state === TodoItemState.Todo), 108 | }, 109 | { 110 | key: TodoItemState.InProgress, 111 | name: 'In Progress', 112 | count: items.filter(i => i.state === TodoItemState.InProgress).length, 113 | startIndex: items.findIndex(i => i.state === TodoItemState.InProgress) 114 | }, 115 | { 116 | key: TodoItemState.Done, 117 | name: 'Done', 118 | count: items.filter(i => i.state === TodoItemState.Done).length, 119 | startIndex: items.findIndex(i => i.state === TodoItemState.Done), 120 | isCollapsed: isDoneCategoryCollapsed 121 | }, 122 | ] 123 | 124 | const onFormSubmit = (evt: FormEvent) => { 125 | evt.preventDefault(); 126 | 127 | if (newItemName && props.onCreated) { 128 | const item: TodoItem = { 129 | name: newItemName, 130 | listId: props.list?.id || '', 131 | state: TodoItemState.Todo, 132 | } 133 | props.onCreated(item); 134 | setNewItemName(''); 135 | } 136 | } 137 | 138 | const onNewItemChanged = (_evt?: FormEvent, value?: string) => { 139 | setNewItemName(value || ''); 140 | } 141 | 142 | const selectItem = (item: TodoDisplayItem) => { 143 | navigate(`/lists/${item.data.listId}/items/${item.data.id}`); 144 | } 145 | 146 | const completeItems = () => { 147 | selectedItems.map(item => props.onComplete(item)); 148 | } 149 | 150 | const deleteItems = () => { 151 | selectedItems.map(item => props.onDelete(item)); 152 | } 153 | 154 | const columns: IColumn[] = [ 155 | { key: 'name', name: 'Name', fieldName: 'name', minWidth: 100 }, 156 | { key: 'dueDate', name: 'Due', fieldName: 'dueDate', minWidth: 100 }, 157 | { key: 'completedDate', name: 'Completed', fieldName: 'completedDate', minWidth: 100 }, 158 | ]; 159 | 160 | const groupRenderProps: IDetailsGroupRenderProps = { 161 | headerProps: { 162 | styles: { 163 | groupHeaderContainer: { 164 | backgroundColor: theme.palette.neutralPrimary 165 | } 166 | } 167 | } 168 | } 169 | 170 | const renderItemColumn = (item: TodoDisplayItem, _index?: number, column?: IColumn) => { 171 | const fieldContent = item[column?.fieldName as keyof TodoDisplayItem] as string; 172 | 173 | switch (column?.key) { 174 | case "name": 175 | return ( 176 | <> 177 | {item.name} 178 | {item.description && 179 | <> 180 | 181 | {item.description} 182 | 183 | } 184 | 185 | ); 186 | default: 187 | return ({fieldContent}) 188 | } 189 | } 190 | 191 | return ( 192 | 193 | 194 |
195 | 196 | 197 | 198 | 199 | 200 | { completeItems() } 208 | }, 209 | { 210 | key: 'delete', 211 | text: 'Delete', 212 | disabled: props.disabled, 213 | iconProps: { iconName: 'Delete' }, 214 | onClick: () => { deleteItems() } 215 | } 216 | ]} 217 | ariaLabel="Todo actions" /> 218 | 219 | 220 |
221 |
222 | {items.length > 0 && 223 | 224 | 225 | 240 | 241 | 242 | } 243 | {!props.items && 244 | 245 | 246 | 247 | 248 | } 249 | {props.items && items.length === 0 && 250 | 251 | This list is empty. 252 | 253 | } 254 |
255 | ); 256 | }; 257 | 258 | export default TodoItemListPane; -------------------------------------------------------------------------------- /src/web/src/components/todoListMenu.tsx: -------------------------------------------------------------------------------- 1 | import { IIconProps, INavLink, INavLinkGroup, Nav, Stack, TextField } from '@fluentui/react'; 2 | import { FC, ReactElement, useState, FormEvent, MouseEvent } from 'react'; 3 | import { useNavigate } from 'react-router'; 4 | import { TodoList } from '../models/todoList'; 5 | import { stackItemPadding } from '../ux/styles'; 6 | 7 | interface TodoListMenuProps { 8 | selectedList?: TodoList 9 | lists?: TodoList[] 10 | onCreate: (list: TodoList) => void 11 | } 12 | 13 | const iconProps: IIconProps = { 14 | iconName: 'AddToShoppingList' 15 | } 16 | 17 | const TodoListMenu: FC = (props: TodoListMenuProps): ReactElement => { 18 | const navigate = useNavigate(); 19 | const [newListName, setNewListName] = useState(''); 20 | 21 | const onNavLinkClick = (evt?: MouseEvent, item?: INavLink) => { 22 | evt?.preventDefault(); 23 | 24 | if (!item) { 25 | return; 26 | } 27 | 28 | navigate(`/lists/${item.key}`); 29 | } 30 | 31 | const createNavGroups = (lists: TodoList[]): INavLinkGroup[] => { 32 | const links = lists.map(list => ({ 33 | key: list.id, 34 | name: list.name, 35 | url: `/lists/${list.id}`, 36 | links: [], 37 | isExpanded: props.selectedList ? list.id === props.selectedList.id : false 38 | })); 39 | 40 | return [{ 41 | links: links 42 | }] 43 | } 44 | 45 | const onNewListNameChange = (_evt: FormEvent, value?: string) => { 46 | setNewListName(value || ''); 47 | } 48 | 49 | const onFormSubmit = async (evt: FormEvent) => { 50 | evt.preventDefault(); 51 | 52 | if (newListName) { 53 | const list: TodoList = { 54 | name: newListName 55 | }; 56 | 57 | props.onCreate(list); 58 | setNewListName(''); 59 | } 60 | } 61 | 62 | return ( 63 | 64 | 65 |