├── .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 ├── OPTIONAL_FEATURES.md ├── README.md ├── assets ├── resources-with-apim.png ├── resources.png ├── urls.png └── web.png ├── azure.yaml ├── infra ├── abbreviations.json ├── app │ └── db-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 -------------------------------------------------------------------------------- /OPTIONAL_FEATURES.md: -------------------------------------------------------------------------------- 1 | ### Enable Additional Features 2 | 3 | #### Enable [Azure API Management](https://learn.microsoft.com/azure/api-management/) 4 | 5 | This template is prepared to use Azure API Management (aka APIM) for backend API protection and observability. APIM supports the complete API lifecycle and abstract backend complexity from API consumers. 6 | 7 | To use APIM on this template you just need to set the environment variable with the following command: 8 | 9 | ```bash 10 | azd env set USE_APIM true 11 | ``` 12 | And then execute `azd up` to provision and deploy. No worries if you already did `azd up`! You can set the `USE_APIM` environment variable at anytime and then just repeat the `azd up` command to run the incremental deployment. 13 | 14 | Here's the high level architecture diagram when APIM is used: 15 | 16 | !["Application architecture diagram with APIM"](assets/resources-with-apim.png) 17 | 18 | The frontend will be configured to make API requests through APIM instead of calling the backend directly, so that the following flow gets executed: 19 | 20 | 1. APIM receives the frontend request, applies the configured policy to enable CORS, validates content and limits concurrency. Follow this [guide](https://learn.microsoft.com/azure/api-management/api-management-howto-policies) to understand how to customize the policy. 21 | 1. If there are no errors, the request is forwarded to the backend and then the backend response is sent back to the frontend. 22 | 1. APIM emits logs, metrics, and traces for monitoring, reporting, and troubleshooting on every execution. Follow this [guide](https://learn.microsoft.com/azure/api-management/api-management-howto-use-azure-monitor) to visualize, query, and take actions on the metrics or logs coming from APIM. 23 | 24 | > NOTE: 25 | > 26 | > By default, this template uses the Consumption tier that is a lightweight and serverless version of API Management service, billed per execution. Please check the [pricing page](https://azure.microsoft.com/pricing/details/api-management/) for more details. 27 | -------------------------------------------------------------------------------- /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-container-apps 13 | - azure-container-registry 14 | - azure-monitor 15 | - azure-pipelines 16 | urlFragment: todo-nodejs-mongo-aca 17 | name: Containerized React Web App with Node.js API and MongoDB on Azure 18 | description: Complete ToDo app on Azure Container Apps with Node.js API and Azure Cosmos API for MongoDB for storage. Uses Azure Developer CLI (azd) to build, deploy, and monitor 19 | --- 20 | 21 | 22 | # Containerized React Web App with Node.js API and MongoDB on Azure 23 | 24 | [![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-aca) 25 | [![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-aca) 26 | 27 | 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. This architecture is for running containerized apps or microservices on a serverless platform. 28 | 29 | 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. 30 | 31 | !["Screenshot of deployed ToDo app"](assets/web.png) 32 | 33 | Screenshot of the deployed ToDo app 34 | 35 | ### Prerequisites 36 | > This template will create infrastructure and deploy code to Azure. If you don't have an Azure Subscription, you can sign up for a [free account here](https://azure.microsoft.com/free/). Make sure you have contributor role to the Azure subscription. 37 | 38 | The following prerequisites are required to use this application. Please ensure that you have them all installed locally. 39 | 40 | - [Azure Developer CLI](https://aka.ms/azd-install) 41 | - [Node.js with npm (18.17.1+)](https://nodejs.org/) - for API backend and Web frontend 42 | - [Docker](https://docs.docker.com/get-docker/) 43 | 44 | ### Quickstart 45 | 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-aca`). 46 | 47 | 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: 48 | 49 | ```bash 50 | # Log in to azd. Only required once per-install. 51 | azd auth login 52 | 53 | # First-time project setup. Initialize a project in the current directory, using this template. 54 | azd init --template Azure-Samples/todo-nodejs-mongo-aca 55 | 56 | # Provision and deploy to Azure 57 | azd up 58 | ``` 59 | 60 | > NOTE: This template may only be used with the following Azure locations: 61 | > 62 | > - Australia East 63 | > - Brazil South 64 | > - Canada Central 65 | > - Central US 66 | > - East Asia 67 | > - East US 68 | > - East US 2 69 | > - Germany West Central 70 | > - Japan East 71 | > - Korea Central 72 | > - North Central US 73 | > - North Europe 74 | > - South Central US 75 | > - UK South 76 | > - West Europe 77 | > - West US 78 | > 79 | > If you attempt to use the template with an unsupported region, the provision step will fail. 80 | 81 | ### Application Architecture 82 | 83 | This application utilizes the following Azure resources: 84 | 85 | - [**Azure Container Apps**](https://docs.microsoft.com/azure/container-apps/) to host the Web frontend and API backend 86 | - [**Azure Cosmos DB API for MongoDB**](https://docs.microsoft.com/azure/cosmos-db/mongodb/mongodb-introduction) for storage 87 | - [**Azure Monitor**](https://docs.microsoft.com/azure/azure-monitor/) for monitoring and logging 88 | - [**Azure Key Vault**](https://docs.microsoft.com/azure/key-vault/) for securing secrets 89 | 90 | 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. 91 | 92 | !["Application architecture diagram"](assets/resources.png) 93 | 94 | ### Cost of provisioning and deploying this template 95 | This template provisions resources to an Azure subscription that you will select upon provisioning them. Refer to the [Pricing calculator for Microsoft Azure](https://azure.microsoft.com/pricing/calculator/) to estimate the cost you might incur when this template is running on Azure and, if needed, update the included Azure resource definitions found in `infra/main.bicep` to suit your needs. 96 | 97 | ### Application Code 98 | 99 | 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). 100 | 101 | ### Next Steps 102 | 103 | 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. 104 | 105 | > Note: Needs to manually install [setup-azd extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd) for Azure DevOps (azdo). 106 | 107 | - [`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. 108 | 109 | - [`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) 110 | 111 | - [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 112 | 113 | - [`azd down`](https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-down) - to delete all the Azure resources created with this template 114 | 115 | - [Enable optional features, like APIM](./OPTIONAL_FEATURES.md) - for enhanced backend API protection and observability 116 | 117 | ### Additional `azd` commands 118 | 119 | 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. 120 | 121 | ## Security 122 | 123 | ### Roles 124 | 125 | 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). 126 | 127 | ### Key Vault 128 | 129 | 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. 130 | 131 | ## Reporting Issues and Feedback 132 | 133 | 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. 134 | -------------------------------------------------------------------------------- /assets/resources-with-apim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo-aca/32dfb6c199ce371762623746dce8580ba4bf9eac/assets/resources-with-apim.png -------------------------------------------------------------------------------- /assets/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo-aca/32dfb6c199ce371762623746dce8580ba4bf9eac/assets/resources.png -------------------------------------------------------------------------------- /assets/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo-aca/32dfb6c199ce371762623746dce8580ba4bf9eac/assets/urls.png -------------------------------------------------------------------------------- /assets/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-nodejs-mongo-aca/32dfb6c199ce371762623746dce8580ba4bf9eac/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-aca 4 | metadata: 5 | template: todo-nodejs-mongo-aca@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 | language: js 15 | host: containerapp 16 | api: 17 | project: ./src/api 18 | language: js 19 | host: containerapp 20 | # using predeploy hook for web until 21 | # https://github.com/Azure/azure-dev/issues/3546 is fixed 22 | hooks: 23 | # Creates a temporary `.env.local` file for the build command. Vite will automatically use it during build. 24 | # The expected/required values are mapped to the infrastructure outputs. 25 | # .env.local is ignored by git, so it will not be committed if, for any reason, if deployment fails. 26 | # see: https://vitejs.dev/guide/env-and-mode 27 | # Note: Notice that dotenv must be a project dependency for this to work. See package.json. 28 | predeploy: 29 | windows: 30 | shell: pwsh 31 | run: 'echo "VITE_API_BASE_URL=""$env:API_BASE_URL""" > ./src/web/.env.local ; echo "VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=""$env:APPLICATIONINSIGHTS_CONNECTION_STRING""" >> ./src/web/.env.local' 32 | posix: 33 | shell: sh 34 | run: 'echo VITE_API_BASE_URL=\"$API_BASE_URL\" > ./src/web/.env.local && echo VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" >> ./src/web/.env.local' 35 | postdeploy: 36 | windows: 37 | shell: pwsh 38 | run: 'rm ./src/web/.env.local' 39 | posix: 40 | shell: sh 41 | run: 'rm ./src/web/.env.local' 42 | 43 | -------------------------------------------------------------------------------- /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/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/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 apiContainerAppName string = '' 17 | param applicationInsightsDashboardName string = '' 18 | param applicationInsightsName string = '' 19 | param containerAppsEnvironmentName string = '' 20 | param containerRegistryName string = '' 21 | param cosmosAccountName string = '' 22 | param keyVaultName string = '' 23 | param logAnalyticsName string = '' 24 | param resourceGroupName string = '' 25 | param webContainerAppName string = '' 26 | param apimServiceName string = '' 27 | param webAppExists bool = false 28 | param apiAppExists bool = false 29 | 30 | @description('Flag to use Azure API Management to mediate the calls between the Web frontend and the backend API') 31 | param useAPIM bool = false 32 | 33 | @description('API Management SKU to use if APIM is enabled') 34 | param apimSku string = 'Consumption' 35 | 36 | @description('Id of the user or app to assign application roles') 37 | param principalId string = '' 38 | 39 | var abbrs = loadJsonContent('./abbreviations.json') 40 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 41 | var tags = { 'azd-env-name': environmentName } 42 | var apiContainerAppNameOrDefault = '${abbrs.appContainerApps}web-${resourceToken}' 43 | var corsAcaUrl = 'https://${apiContainerAppNameOrDefault}.${containerApps.outputs.defaultDomain}' 44 | 45 | // Organize resources in a resource group 46 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 47 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 48 | location: location 49 | tags: tags 50 | } 51 | 52 | // Container apps host (including container registry) 53 | module containerApps 'br/public:avm/ptn/azd/container-apps-stack:0.1.0' = { 54 | name: 'container-apps' 55 | scope: rg 56 | params: { 57 | containerAppsEnvironmentName: !empty(containerAppsEnvironmentName) ? containerAppsEnvironmentName : '${abbrs.appManagedEnvironments}${resourceToken}' 58 | containerRegistryName: !empty(containerRegistryName) ? containerRegistryName : '${abbrs.containerRegistryRegistries}${resourceToken}' 59 | logAnalyticsWorkspaceResourceId: monitoring.outputs.logAnalyticsWorkspaceResourceId 60 | appInsightsConnectionString: monitoring.outputs.applicationInsightsConnectionString 61 | acrSku: 'Basic' 62 | location: location 63 | acrAdminUserEnabled: true 64 | zoneRedundant: false 65 | tags: tags 66 | } 67 | } 68 | 69 | //the managed identity for web frontend 70 | module webIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.2.1' = { 71 | name: 'webidentity' 72 | scope: rg 73 | params: { 74 | name: '${abbrs.managedIdentityUserAssignedIdentities}web-${resourceToken}' 75 | location: location 76 | } 77 | } 78 | 79 | // Web frontend 80 | module web 'br/public:avm/ptn/azd/container-app-upsert:0.1.1' = { 81 | name: 'web-container-app' 82 | scope: rg 83 | params: { 84 | name: !empty(webContainerAppName) ? webContainerAppName : '${abbrs.appContainerApps}web-${resourceToken}' 85 | tags: union(tags, { 'azd-service-name': 'web' }) 86 | location: location 87 | containerAppsEnvironmentName: containerApps.outputs.environmentName 88 | containerRegistryName: containerApps.outputs.registryName 89 | ingressEnabled: true 90 | identityType: 'UserAssigned' 91 | exists: webAppExists 92 | containerName: 'main' 93 | identityName: webIdentity.name 94 | userAssignedIdentityResourceId: webIdentity.outputs.resourceId 95 | containerMinReplicas: 1 96 | identityPrincipalId: webIdentity.outputs.principalId 97 | } 98 | } 99 | 100 | //the managed identity for api backend 101 | module apiIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.2.1' = { 102 | name: 'apiidentity' 103 | scope: rg 104 | params: { 105 | name: '${abbrs.managedIdentityUserAssignedIdentities}api-${resourceToken}' 106 | location: location 107 | } 108 | } 109 | 110 | // Api backend 111 | module api 'br/public:avm/ptn/azd/container-app-upsert:0.1.1' = { 112 | name: 'api-container-app' 113 | scope: rg 114 | params: { 115 | name: !empty(apiContainerAppName) ? apiContainerAppName : '${abbrs.appContainerApps}api-${resourceToken}' 116 | tags: union(tags, { 'azd-service-name': 'api' }) 117 | location: location 118 | env: [ 119 | { 120 | name: 'AZURE_CLIENT_ID' 121 | value: apiIdentity.outputs.clientId 122 | } 123 | { 124 | name: 'AZURE_KEY_VAULT_ENDPOINT' 125 | value: keyVault.outputs.uri 126 | } 127 | { 128 | name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' 129 | value: monitoring.outputs.applicationInsightsConnectionString 130 | } 131 | { 132 | name: 'API_ALLOW_ORIGINS' 133 | value: corsAcaUrl 134 | } 135 | ] 136 | containerAppsEnvironmentName: containerApps.outputs.environmentName 137 | containerRegistryName: containerApps.outputs.registryName 138 | exists: apiAppExists 139 | identityType: 'UserAssigned' 140 | identityName: apiIdentity.name 141 | containerCpuCoreCount: '1.0' 142 | containerMemory: '2.0Gi' 143 | targetPort: 3100 144 | containerMinReplicas: 1 145 | ingressEnabled: true 146 | containerName: 'main' 147 | userAssignedIdentityResourceId: apiIdentity.outputs.resourceId 148 | identityPrincipalId: apiIdentity.outputs.principalId 149 | } 150 | } 151 | 152 | // The application database 153 | module cosmos './app/db-avm.bicep' = { 154 | name: 'cosmos' 155 | scope: rg 156 | params: { 157 | accountName: !empty(cosmosAccountName) ? cosmosAccountName : '${abbrs.documentDBDatabaseAccounts}${resourceToken}' 158 | location: location 159 | tags: tags 160 | keyVaultResourceId: keyVault.outputs.resourceId 161 | } 162 | } 163 | 164 | // Create a keyvault to store secrets 165 | module keyVault 'br/public:avm/res/key-vault/vault:0.5.1' = { 166 | name: 'keyvault' 167 | scope: rg 168 | params: { 169 | name: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' 170 | location: location 171 | tags: tags 172 | enableRbacAuthorization: false 173 | enableVaultForDeployment: false 174 | enableVaultForTemplateDeployment: false 175 | enablePurgeProtection: false 176 | sku: 'standard' 177 | accessPolicies: [ 178 | { 179 | objectId: principalId 180 | permissions: { 181 | secrets: [ 'get', 'list' ] 182 | } 183 | } 184 | { 185 | objectId: apiIdentity.outputs.principalId 186 | permissions: { 187 | secrets: [ 'get', 'list' ] 188 | } 189 | } 190 | ] 191 | } 192 | } 193 | 194 | // Monitor application with Azure Monitor 195 | module monitoring 'br/public:avm/ptn/azd/monitoring:0.1.0' = { 196 | name: 'monitoring' 197 | scope: rg 198 | params: { 199 | applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' 200 | logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 201 | applicationInsightsDashboardName: !empty(applicationInsightsDashboardName) ? applicationInsightsDashboardName : '${abbrs.portalDashboards}${resourceToken}' 202 | location: location 203 | tags: tags 204 | } 205 | } 206 | 207 | // Creates Azure API Management (APIM) service to mediate the requests between the frontend and the backend API 208 | module apim 'br/public:avm/res/api-management/service:0.2.0' = if (useAPIM) { 209 | name: 'apim-deployment' 210 | scope: rg 211 | params: { 212 | name: !empty(apimServiceName) ? apimServiceName : '${abbrs.apiManagementService}${resourceToken}' 213 | publisherEmail: 'noreply@microsoft.com' 214 | publisherName: 'n/a' 215 | location: location 216 | tags: tags 217 | sku: apimSku 218 | skuCount: 0 219 | zones: [] 220 | customProperties: {} 221 | loggers: [ 222 | { 223 | name: 'app-insights-logger' 224 | credentials: { 225 | instrumentationKey: monitoring.outputs.applicationInsightsInstrumentationKey 226 | } 227 | loggerDescription: 'Logger to Azure Application Insights' 228 | isBuffered: false 229 | loggerType: 'applicationInsights' 230 | targetResourceId: monitoring.outputs.applicationInsightsResourceId 231 | } 232 | ] 233 | } 234 | } 235 | 236 | //Configures the API settings for an api app within the Azure API Management (APIM) service. 237 | module apimApi 'br/public:avm/ptn/azd/apim-api:0.1.0' = if (useAPIM) { 238 | name: 'apim-api-deployment' 239 | scope: rg 240 | params: { 241 | apiBackendUrl: api.outputs.uri 242 | apiDescription: 'This is a simple Todo API' 243 | apiDisplayName: 'Simple Todo API' 244 | apiName: 'todo-api' 245 | apiPath: 'todo' 246 | name: useAPIM ? apim.outputs.name : '' 247 | webFrontendUrl: web.outputs.uri 248 | location: location 249 | } 250 | } 251 | 252 | // Data outputs 253 | output AZURE_COSMOS_CONNECTION_STRING_KEY string = cosmos.outputs.connectionStringKey 254 | output AZURE_COSMOS_DATABASE_NAME string = cosmos.outputs.databaseName 255 | 256 | // App outputs 257 | output API_CORS_ACA_URL string = corsAcaUrl 258 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString 259 | output APPLICATIONINSIGHTS_NAME string = monitoring.outputs.applicationInsightsName 260 | output AZURE_CONTAINER_ENVIRONMENT_NAME string = containerApps.outputs.environmentName 261 | output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerApps.outputs.registryLoginServer 262 | output AZURE_CONTAINER_REGISTRY_NAME string = containerApps.outputs.registryName 263 | output AZURE_KEY_VAULT_ENDPOINT string = keyVault.outputs.uri 264 | output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name 265 | output AZURE_LOCATION string = location 266 | output AZURE_TENANT_ID string = tenant().tenantId 267 | output API_BASE_URL string = useAPIM ? apimApi.outputs.serviceApiUri : api.outputs.uri 268 | output REACT_APP_WEB_BASE_URL string = web.outputs.uri 269 | output SERVICE_API_NAME string = api.outputs.name 270 | output SERVICE_WEB_NAME string = web.outputs.name 271 | output USE_APIM bool = useAPIM 272 | output SERVICE_API_ENDPOINTS array = useAPIM ? [ apimApi.outputs.serviceApiUri, api.outputs.uri ] : [] 273 | -------------------------------------------------------------------------------- /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 | "apiAppExists": { 15 | "value": "${SERVICE_API_RESOURCE_EXISTS=false}" 16 | }, 17 | "webAppExists": { 18 | "value": "${SERVICE_WEB_RESOURCE_EXISTS=false}" 19 | }, 20 | "webApiBaseUrl": { 21 | "value": "${REACT_APP_API_BASE_URL}" 22 | }, 23 | "useAPIM": { 24 | "value": "${USE_APIM=false}" 25 | }, 26 | "apimSku": { 27 | "value": "${APIM_SKU=Consumption}" 28 | }, 29 | "containerRegistryHostSuffix": { 30 | "value": "${CONTAINER_REGISTRY_HOST_SUFFIX=azurecr.io}" 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /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-aca/32dfb6c199ce371762623746dce8580ba4bf9eac/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 |