├── .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 │ ├── api-appservice-avm.bicep │ ├── db-avm.bicep │ └── web-appservice-avm.bicep ├── main.bicep └── main.parameters.json ├── openapi.yaml ├── src ├── api │ ├── .gitignore │ ├── ListsRepository.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Todo.Api.csproj │ ├── Todo.Api.sln │ ├── TodoEndpointsExtensions.cs │ ├── TodoItem.cs │ ├── TodoList.cs │ ├── appsettings.Development.json │ ├── appsettings.json │ └── wwwroot │ │ └── openapi.yaml └── 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/dotnet:8.0-bookworm", 4 | "features": { 5 | "ghcr.io/devcontainers/features/docker-in-docker:2": { 6 | }, 7 | "ghcr.io/devcontainers/features/node:1": { 8 | "version": "18", 9 | "nodeGypDependencies": false 10 | }, 11 | "ghcr.io/azure/azure-dev/azd:latest": {} 12 | }, 13 | "customizations": { 14 | "vscode": { 15 | "extensions": [ 16 | "GitHub.vscode-github-actions", 17 | "ms-azuretools.azure-dev", 18 | "ms-azuretools.vscode-azurefunctions", 19 | "ms-azuretools.vscode-bicep", 20 | "ms-azuretools.vscode-docker", 21 | "ms-dotnettools.csharp", 22 | "ms-dotnettools.vscode-dotnet-runtime", 23 | "ms-vscode.vscode-node-azure-pack" 24 | ] 25 | } 26 | }, 27 | "forwardPorts": [ 28 | 3000, 29 | 3100 30 | ], 31 | "postCreateCommand": "", 32 | "remoteUser": "vscode", 33 | "hostRequirements": { 34 | "memory": "8gb" 35 | } 36 | } -------------------------------------------------------------------------------- /.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_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 27 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Install azd 33 | uses: Azure/setup-azd@v2 34 | 35 | - name: Log in with Azure (Federated Credentials) 36 | if: ${{ env.AZURE_CLIENT_ID != '' }} 37 | run: | 38 | azd auth login ` 39 | --client-id "$Env:AZURE_CLIENT_ID" ` 40 | --federated-credential-provider "github" ` 41 | --tenant-id "$Env:AZURE_TENANT_ID" 42 | shell: pwsh 43 | 44 | - name: Log in with Azure (Client Credentials) 45 | if: ${{ env.AZURE_CREDENTIALS != '' }} 46 | run: | 47 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 48 | Write-Host "::add-mask::$($info.clientSecret)" 49 | 50 | azd auth login ` 51 | --client-id "$($info.clientId)" ` 52 | --client-secret "$($info.clientSecret)" ` 53 | --tenant-id "$($info.tenantId)" 54 | shell: pwsh 55 | env: 56 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 57 | 58 | - name: Provision Infrastructure 59 | run: azd provision --no-prompt 60 | env: 61 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 62 | 63 | - name: Deploy Application 64 | run: azd deploy --no-prompt 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .azure -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.azure-dev" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Web", 6 | "request": "launch", 7 | "type": "msedge", 8 | "webRoot": "${workspaceFolder}/src/web/src", 9 | "url": "http://localhost:3000", 10 | "sourceMapPathOverrides": { 11 | "webpack:///src/*": "${webRoot}/*" 12 | }, 13 | }, 14 | 15 | { 16 | // Use IntelliSense to find out which attributes exist for C# debugging 17 | // Use hover for the description of the existing attributes 18 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 19 | "name": "Debug API", 20 | "type": "coreclr", 21 | "request": "launch", 22 | "preLaunchTask": "Build API", 23 | // If you have changed target frameworks, make sure to update the program path. 24 | "program": "${workspaceFolder}/src/api/bin/Debug/net8.0/Todo.Api.dll", 25 | "args": [], 26 | "cwd": "${workspaceFolder}/src/api", 27 | "stopAtEntry": false, 28 | "env": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | }, 31 | "envFile": "${input:dotEnvFilePath}" 32 | }, 33 | 34 | { 35 | "name": ".NET Core Attach", 36 | "type": "coreclr", 37 | "request": "attach" 38 | } 39 | ], 40 | 41 | "inputs": [ 42 | { 43 | "id": "dotEnvFilePath", 44 | "type": "command", 45 | "command": "azure-dev.commands.getDotEnvFilePath" 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Start API", 6 | "type": "dotenv", 7 | "targetTasks": "API dotnet run", 8 | "file": "${input:dotEnvFilePath}" 9 | }, 10 | { 11 | "label": "API dotnet run", 12 | "detail": "Helper task--use 'Start API' task to ensure environment is set up correctly", 13 | "type": "shell", 14 | "command": "dotnet run", 15 | "options": { 16 | "cwd": "${workspaceFolder}/src/api/" 17 | }, 18 | "presentation": { 19 | "panel": "dedicated", 20 | }, 21 | "problemMatcher": [] 22 | }, 23 | { 24 | "label": "Build API", 25 | "command": "dotnet", 26 | "type": "process", 27 | "args": [ 28 | "build", 29 | "${workspaceFolder}/src/api/Todo.Api.csproj", 30 | "/property:GenerateFullPaths=true", 31 | "/consoleloggerparameters:NoSummary" 32 | ], 33 | "problemMatcher": "$msCompile" 34 | }, 35 | { 36 | "label": "Watch API", 37 | "command": "dotnet", 38 | "type": "process", 39 | "args": [ 40 | "watch", 41 | "run", 42 | "--project", 43 | "${workspaceFolder}/src/api/Todo.Api.csproj" 44 | ], 45 | "problemMatcher": "$msCompile" 46 | }, 47 | 48 | { 49 | "label": "Start Web", 50 | "type": "dotenv", 51 | "targetTasks": [ 52 | "Restore Web", 53 | "Web npm start" 54 | ], 55 | "file": "${input:dotEnvFilePath}" 56 | }, 57 | { 58 | "label": "Restore Web", 59 | "type": "shell", 60 | "command": "azd restore web", 61 | "presentation": { 62 | "reveal": "silent" 63 | }, 64 | "problemMatcher": [] 65 | }, 66 | { 67 | "label": "Web npm start", 68 | "detail": "Helper task--use 'Start Web' task to ensure environment is set up correctly", 69 | "type": "shell", 70 | "command": "npx -y cross-env VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" npm run dev", 71 | "options": { 72 | "cwd": "${workspaceFolder}/src/web/", 73 | "env": { 74 | "VITE_API_BASE_URL": "http://localhost:3100", 75 | "BROWSER": "none" 76 | } 77 | }, 78 | "presentation": { 79 | "panel": "dedicated", 80 | }, 81 | "problemMatcher": [] 82 | }, 83 | 84 | { 85 | "label": "Start API and Web", 86 | "dependsOn":[ 87 | "Start API", 88 | "Start Web" 89 | ], 90 | "problemMatcher": [] 91 | } 92 | ], 93 | 94 | "inputs": [ 95 | { 96 | "id": "dotEnvFilePath", 97 | "type": "command", 98 | "command": "azure-dev.commands.getDotEnvFilePath" 99 | } 100 | ] 101 | } 102 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | languages: 4 | - azdeveloper 5 | - aspx-csharp 6 | - csharp 7 | - bicep 8 | - typescript 9 | - html 10 | products: 11 | - azure 12 | - azure-cosmos-db 13 | - azure-app-service 14 | - azure-monitor 15 | - azure-pipelines 16 | - aspnet-core 17 | urlFragment: todo-csharp-cosmos-sql 18 | name: React Web App with C# API and Cosmos DB for NoSQL on Azure 19 | description: A complete ToDo app with C# API and Azure Cosmos DB (NoSQL) for storage. Uses Azure Developer CLI (azd) to build, deploy, and monitor 20 | --- 21 | 22 | 23 | # React Web App with C# API and Cosmos DB for NoSQL on Azure 24 | 25 | [![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-csharp-cosmos-sql) 26 | [![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-csharp-cosmos-sql) 27 | 28 | A blueprint for getting a React web app with a C# 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. 29 | 30 | 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. 31 | 32 | !["Screenshot of deployed ToDo app"](assets/web.png) 33 | 34 | Screenshot of the deployed ToDo app 35 | 36 | ### Prerequisites 37 | > 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. 38 | 39 | 40 | The following prerequisites are required to use this application. Please ensure that you have them all installed locally. 41 | 42 | - [Azure Developer CLI](https://aka.ms/azd-install) 43 | - [.NET SDK 8.0](https://dotnet.microsoft.com/download/dotnet/8.0) - for the API backend 44 | - [Node.js with npm (18.17.1+)](https://nodejs.org/) - for the Web frontend 45 | 46 | ### Quickstart 47 | 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-csharp) with this template (`Azure-Samples/todo-csharp-cosmos-sql`). 48 | 49 | 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: 50 | 51 | ```bash 52 | # Log in to azd. Only required once per-install. 53 | azd auth login 54 | 55 | # First-time project setup. Initialize a project in the current directory, using this template. 56 | azd init --template Azure-Samples/todo-csharp-cosmos-sql 57 | 58 | # Provision and deploy to Azure 59 | azd up 60 | ``` 61 | 62 | ### Application Architecture 63 | 64 | This application utilizes the following Azure resources: 65 | 66 | - [**Azure App Services**](https://docs.microsoft.com/azure/app-service/) to host the Web frontend and API backend 67 | - [**Azure Cosmos DB for NoSQL**](https://docs.microsoft.com/learn/modules/intro-to-azure-cosmos-db-core-api/) for storage 68 | - [**Azure Monitor**](https://docs.microsoft.com/azure/azure-monitor/) for monitoring and logging 69 | - [**Azure Key Vault**](https://docs.microsoft.com/azure/key-vault/) for securing secrets 70 | 71 | 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. 72 | 73 | !["Application architecture diagram"](assets/resources.png) 74 | 75 | ### Cost of provisioning and deploying this template 76 | 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. 77 | 78 | ### Application Code 79 | 80 | 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). 81 | 82 | ### Next Steps 83 | 84 | 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. 85 | 86 | > Note: Needs to manually install [setup-azd extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd) for Azure DevOps (azdo). 87 | 88 | - [`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. 89 | 90 | - [`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) 91 | 92 | - [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 93 | 94 | - [`azd down`](https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-down) - to delete all the Azure resources created with this template 95 | 96 | - [Enable optional features, like APIM](./OPTIONAL_FEATURES.md) - for enhanced backend API protection and observability 97 | 98 | ### Additional `azd` commands 99 | 100 | 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. 101 | 102 | 103 | ## Security 104 | 105 | ### Roles 106 | 107 | 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). 108 | 109 | ### Key Vault 110 | 111 | 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. 112 | 113 | ## Reporting Issues and Feedback 114 | 115 | 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. 116 | -------------------------------------------------------------------------------- /assets/resources-with-apim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-csharp-cosmos-sql/63b89b6dca0fdbc36bf758b816e537105e520c31/assets/resources-with-apim.png -------------------------------------------------------------------------------- /assets/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-csharp-cosmos-sql/63b89b6dca0fdbc36bf758b816e537105e520c31/assets/resources.png -------------------------------------------------------------------------------- /assets/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-csharp-cosmos-sql/63b89b6dca0fdbc36bf758b816e537105e520c31/assets/urls.png -------------------------------------------------------------------------------- /assets/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-csharp-cosmos-sql/63b89b6dca0fdbc36bf758b816e537105e520c31/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-csharp-cosmos-sql 4 | metadata: 5 | template: todo-csharp-cosmos-sql@0.0.1-beta 6 | workflows: 7 | up: 8 | steps: 9 | - azd: provision 10 | - azd: deploy --all 11 | services: 12 | web: 13 | project: ./src/web 14 | dist: dist 15 | language: js 16 | host: appservice 17 | hooks: 18 | # Creates a temporary `.env.local` file for the build command. Vite will automatically use it during build. 19 | # The expected/required values are mapped to the infrastructure outputs. 20 | # .env.local is ignored by git, so it will not be committed if, for any reason, if deployment fails. 21 | # see: https://vitejs.dev/guide/env-and-mode 22 | # Note: Notice that dotenv must be a project dependency for this to work. See package.json. 23 | prepackage: 24 | windows: 25 | shell: pwsh 26 | run: 'echo "VITE_API_BASE_URL=""$env:API_BASE_URL""" > .env.local ; echo "VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=""$env:APPLICATIONINSIGHTS_CONNECTION_STRING""" >> .env.local' 27 | posix: 28 | shell: sh 29 | run: 'echo VITE_API_BASE_URL=\"$API_BASE_URL\" > .env.local && echo VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" >> .env.local' 30 | postdeploy: 31 | windows: 32 | shell: pwsh 33 | run: 'rm .env.local' 34 | posix: 35 | shell: sh 36 | run: 'rm .env.local' 37 | api: 38 | project: ./src/api 39 | language: csharp 40 | host: appservice 41 | -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- 1 | { 2 | "analysisServicesServers": "as", 3 | "apiManagementService": "apim-", 4 | "appConfigurationStores": "appcs-", 5 | "appManagedEnvironments": "cae-", 6 | "appContainerApps": "ca-", 7 | "authorizationPolicyDefinitions": "policy-", 8 | "automationAutomationAccounts": "aa-", 9 | "blueprintBlueprints": "bp-", 10 | "blueprintBlueprintsArtifacts": "bpa-", 11 | "cacheRedis": "redis-", 12 | "cdnProfiles": "cdnp-", 13 | "cdnProfilesEndpoints": "cdne-", 14 | "cognitiveServicesAccounts": "cog-", 15 | "cognitiveServicesFormRecognizer": "cog-fr-", 16 | "cognitiveServicesTextAnalytics": "cog-ta-", 17 | "cognitiveServicesSpeech": "cog-sp-", 18 | "computeAvailabilitySets": "avail-", 19 | "computeCloudServices": "cld-", 20 | "computeDiskEncryptionSets": "des", 21 | "computeDisks": "disk", 22 | "computeDisksOs": "osdisk", 23 | "computeGalleries": "gal", 24 | "computeSnapshots": "snap-", 25 | "computeVirtualMachines": "vm", 26 | "computeVirtualMachineScaleSets": "vmss-", 27 | "containerInstanceContainerGroups": "ci", 28 | "containerRegistryRegistries": "cr", 29 | "containerServiceManagedClusters": "aks-", 30 | "databricksWorkspaces": "dbw-", 31 | "dataFactoryFactories": "adf-", 32 | "dataLakeAnalyticsAccounts": "dla", 33 | "dataLakeStoreAccounts": "dls", 34 | "dataMigrationServices": "dms-", 35 | "dBforMySQLServers": "mysql-", 36 | "dBforPostgreSQLServers": "psql-", 37 | "devicesIotHubs": "iot-", 38 | "devicesProvisioningServices": "provs-", 39 | "devicesProvisioningServicesCertificates": "pcert-", 40 | "documentDBDatabaseAccounts": "cosmos-", 41 | "eventGridDomains": "evgd-", 42 | "eventGridDomainsTopics": "evgt-", 43 | "eventGridEventSubscriptions": "evgs-", 44 | "eventHubNamespaces": "evhns-", 45 | "eventHubNamespacesEventHubs": "evh-", 46 | "hdInsightClustersHadoop": "hadoop-", 47 | "hdInsightClustersHbase": "hbase-", 48 | "hdInsightClustersKafka": "kafka-", 49 | "hdInsightClustersMl": "mls-", 50 | "hdInsightClustersSpark": "spark-", 51 | "hdInsightClustersStorm": "storm-", 52 | "hybridComputeMachines": "arcs-", 53 | "insightsActionGroups": "ag-", 54 | "insightsComponents": "appi-", 55 | "keyVaultVaults": "kv-", 56 | "kubernetesConnectedClusters": "arck", 57 | "kustoClusters": "dec", 58 | "kustoClustersDatabases": "dedb", 59 | "loadTesting": "lt-", 60 | "logicIntegrationAccounts": "ia-", 61 | "logicWorkflows": "logic-", 62 | "machineLearningServicesWorkspaces": "mlw-", 63 | "managedIdentityUserAssignedIdentities": "id-", 64 | "managementManagementGroups": "mg-", 65 | "migrateAssessmentProjects": "migr-", 66 | "networkApplicationGateways": "agw-", 67 | "networkApplicationSecurityGroups": "asg-", 68 | "networkAzureFirewalls": "afw-", 69 | "networkBastionHosts": "bas-", 70 | "networkConnections": "con-", 71 | "networkDnsZones": "dnsz-", 72 | "networkExpressRouteCircuits": "erc-", 73 | "networkFirewallPolicies": "afwp-", 74 | "networkFirewallPoliciesWebApplication": "waf", 75 | "networkFirewallPoliciesRuleGroups": "wafrg", 76 | "networkFrontDoors": "fd-", 77 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", 78 | "networkLoadBalancersExternal": "lbe-", 79 | "networkLoadBalancersInternal": "lbi-", 80 | "networkLoadBalancersInboundNatRules": "rule-", 81 | "networkLocalNetworkGateways": "lgw-", 82 | "networkNatGateways": "ng-", 83 | "networkNetworkInterfaces": "nic-", 84 | "networkNetworkSecurityGroups": "nsg-", 85 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", 86 | "networkNetworkWatchers": "nw-", 87 | "networkPrivateDnsZones": "pdnsz-", 88 | "networkPrivateLinkServices": "pl-", 89 | "networkPublicIPAddresses": "pip-", 90 | "networkPublicIPPrefixes": "ippre-", 91 | "networkRouteFilters": "rf-", 92 | "networkRouteTables": "rt-", 93 | "networkRouteTablesRoutes": "udr-", 94 | "networkTrafficManagerProfiles": "traf-", 95 | "networkVirtualNetworkGateways": "vgw-", 96 | "networkVirtualNetworks": "vnet-", 97 | "networkVirtualNetworksSubnets": "snet-", 98 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-", 99 | "networkVirtualWans": "vwan-", 100 | "networkVpnGateways": "vpng-", 101 | "networkVpnGatewaysVpnConnections": "vcn-", 102 | "networkVpnGatewaysVpnSites": "vst-", 103 | "notificationHubsNamespaces": "ntfns-", 104 | "notificationHubsNamespacesNotificationHubs": "ntf-", 105 | "operationalInsightsWorkspaces": "log-", 106 | "portalDashboards": "dash-", 107 | "powerBIDedicatedCapacities": "pbi-", 108 | "purviewAccounts": "pview-", 109 | "recoveryServicesVaults": "rsv-", 110 | "resourcesResourceGroups": "rg-", 111 | "searchSearchServices": "srch-", 112 | "serviceBusNamespaces": "sb-", 113 | "serviceBusNamespacesQueues": "sbq-", 114 | "serviceBusNamespacesTopics": "sbt-", 115 | "serviceEndPointPolicies": "se-", 116 | "serviceFabricClusters": "sf-", 117 | "signalRServiceSignalR": "sigr", 118 | "sqlManagedInstances": "sqlmi-", 119 | "sqlServers": "sql-", 120 | "sqlServersDataWarehouse": "sqldw-", 121 | "sqlServersDatabases": "sqldb-", 122 | "sqlServersDatabasesStretch": "sqlstrdb-", 123 | "storageStorageAccounts": "st", 124 | "storageStorageAccountsVm": "stvm", 125 | "storSimpleManagers": "ssimp", 126 | "streamAnalyticsCluster": "asa-", 127 | "synapseWorkspaces": "syn", 128 | "synapseWorkspacesAnalyticsWorkspaces": "synw", 129 | "synapseWorkspacesSqlPoolsDedicated": "syndp", 130 | "synapseWorkspacesSqlPoolsSpark": "synsp", 131 | "timeSeriesInsightsEnvironments": "tsi-", 132 | "webServerFarms": "plan-", 133 | "webSitesAppService": "app-", 134 | "webSitesAppServiceEnvironment": "ase-", 135 | "webSitesFunctions": "func-", 136 | "webStaticSites": "stapp-" 137 | } 138 | -------------------------------------------------------------------------------- /infra/app/api-appservice-avm.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param allowedOrigins array = [] 6 | param appCommandLine string? 7 | param appInsightResourceId string 8 | param appServicePlanId string 9 | @secure() 10 | param appSettings object = {} 11 | param siteConfig object = {} 12 | param serviceName string = 'api' 13 | 14 | @description('Required. Type of site to deploy.') 15 | param kind string 16 | 17 | @description('Optional. If client affinity is enabled.') 18 | param clientAffinityEnabled bool = true 19 | 20 | @description('Optional. Required if app of kind functionapp. Resource ID of the storage account to manage triggers and logging function executions.') 21 | param storageAccountResourceId string? 22 | 23 | module api 'br/public:avm/res/web/site:0.6.0' = { 24 | name: '${name}-app-module' 25 | params: { 26 | kind: kind 27 | name: name 28 | serverFarmResourceId: appServicePlanId 29 | tags: union(tags, { 'azd-service-name': serviceName }) 30 | location: location 31 | appInsightResourceId: appInsightResourceId 32 | clientAffinityEnabled: clientAffinityEnabled 33 | storageAccountResourceId: storageAccountResourceId 34 | managedIdentities: { 35 | systemAssigned: true 36 | } 37 | siteConfig: union(siteConfig, { 38 | cors: { 39 | allowedOrigins: union(['https://portal.azure.com', 'https://ms.portal.azure.com'], allowedOrigins) 40 | } 41 | appCommandLine: appCommandLine 42 | }) 43 | appSettingsKeyValuePairs: union( 44 | appSettings, 45 | { ENABLE_ORYX_BUILD: true, ApplicationInsightsAgent_EXTENSION_VERSION: contains(kind, 'linux') ? '~3' : '~2' } 46 | ) 47 | logsConfiguration: { 48 | applicationLogs: { fileSystem: { level: 'Verbose' } } 49 | detailedErrorMessages: { enabled: true } 50 | failedRequestsTracing: { enabled: true } 51 | httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } 52 | } 53 | } 54 | } 55 | 56 | output SERVICE_API_IDENTITY_PRINCIPAL_ID string = api.outputs.systemAssignedMIPrincipalId 57 | output SERVICE_API_NAME string = api.outputs.name 58 | output SERVICE_API_URI string = 'https://${api.outputs.defaultHostname}' 59 | -------------------------------------------------------------------------------- /infra/app/db-avm.bicep: -------------------------------------------------------------------------------- 1 | param accountName string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' 5 | param databaseName string = '' 6 | param keyVaultResourceId string 7 | param principalId string = '' 8 | 9 | @allowed([ 10 | 'Periodic' 11 | 'Continuous' 12 | ]) 13 | @description('Optional. Default to Continuous. Describes the mode of backups. Periodic backup must be used if multiple write locations are used.') 14 | param backupPolicyType string = 'Continuous' 15 | 16 | var defaultDatabaseName = 'Todo' 17 | var actualDatabaseName = !empty(databaseName) ? databaseName : defaultDatabaseName 18 | 19 | module cosmos 'br/public:avm/res/document-db/database-account:0.6.0' = { 20 | name: 'cosmos-sql' 21 | params: { 22 | name: accountName 23 | location: location 24 | tags: tags 25 | backupPolicyType: backupPolicyType 26 | locations: [ 27 | { 28 | failoverPriority: 0 29 | locationName: location 30 | isZoneRedundant: false 31 | } 32 | ] 33 | secretsExportConfiguration:{ 34 | keyVaultResourceId: keyVaultResourceId 35 | primaryWriteConnectionStringSecretName: connectionStringKey 36 | } 37 | capabilitiesToAdd: [ 'EnableServerless' ] 38 | automaticFailover: false 39 | sqlDatabases: [ 40 | { 41 | name: actualDatabaseName 42 | containers: [ 43 | { 44 | name: 'TodoList' 45 | paths: [ 'id' ] 46 | } 47 | { 48 | name: 'TodoItem' 49 | paths: [ 'id' ] 50 | } 51 | ] 52 | } 53 | ] 54 | sqlRoleAssignmentsPrincipalIds: [ principalId ] 55 | sqlRoleDefinitions: [ 56 | { 57 | name: 'writer' 58 | } 59 | ] 60 | } 61 | } 62 | 63 | output accountName string = cosmos.outputs.name 64 | output connectionStringKey string = connectionStringKey 65 | output databaseName string = actualDatabaseName 66 | output endpoint string = cosmos.outputs.endpoint 67 | -------------------------------------------------------------------------------- /infra/app/web-appservice-avm.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | param serviceName string = 'web' 5 | param appCommandLine string = 'pm2 serve /home/site/wwwroot --no-daemon --spa' 6 | param appInsightResourceId string 7 | param appServicePlanId string 8 | param linuxFxVersion string 9 | param kind string = 'app,linux' 10 | 11 | module web 'br/public:avm/res/web/site:0.6.0' = { 12 | name: '${name}-deployment' 13 | params: { 14 | kind: kind 15 | name: name 16 | serverFarmResourceId: appServicePlanId 17 | tags: union(tags, { 'azd-service-name': serviceName }) 18 | location: location 19 | appInsightResourceId: appInsightResourceId 20 | siteConfig: { 21 | appCommandLine: appCommandLine 22 | linuxFxVersion: linuxFxVersion 23 | alwaysOn: true 24 | } 25 | logsConfiguration: { 26 | applicationLogs: { fileSystem: { level: 'Verbose' } } 27 | detailedErrorMessages: { enabled: true } 28 | failedRequestsTracing: { enabled: true } 29 | httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } 30 | } 31 | appSettingsKeyValuePairs: { ApplicationInsightsAgent_EXTENSION_VERSION: contains(kind, 'linux') ? '~3' : '~2' } 32 | } 33 | } 34 | 35 | output SERVICE_WEB_IDENTITY_PRINCIPAL_ID string = web.outputs.systemAssignedMIPrincipalId 36 | output SERVICE_WEB_NAME string = web.outputs.name 37 | output SERVICE_WEB_URI string = 'https://${web.outputs.defaultHostname}' 38 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | @minLength(1) 4 | @maxLength(64) 5 | @description('Name of the the environment which is used to generate a short unique hash used in all resources.') 6 | param environmentName string 7 | 8 | @minLength(1) 9 | @description('Primary location for all resources') 10 | param location string 11 | 12 | // Optional parameters to override the default azd resource naming conventions. Update the main.parameters.json file to provide values. e.g.,: 13 | // "resourceGroupName": { 14 | // "value": "myGroupName" 15 | // } 16 | param apiServiceName string = '' 17 | param applicationInsightsDashboardName string = '' 18 | param applicationInsightsName string = '' 19 | param appServicePlanName string = '' 20 | param cosmosAccountName string = '' 21 | param keyVaultName string = '' 22 | param logAnalyticsName string = '' 23 | param resourceGroupName string = '' 24 | param webServiceName string = '' 25 | param apimServiceName string = '' 26 | 27 | @description('Flag to use Azure API Management to mediate the calls between the Web frontend and the backend API') 28 | param useAPIM bool = false 29 | 30 | @description('API Management SKU to use if APIM is enabled') 31 | param apimSku string = 'Consumption' 32 | 33 | @description('Id of the user or app to assign application roles') 34 | param principalId string = '' 35 | 36 | var abbrs = loadJsonContent('./abbreviations.json') 37 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 38 | var tags = { 'azd-env-name': environmentName } 39 | 40 | // Organize resources in a resource group 41 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 42 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 43 | location: location 44 | tags: tags 45 | } 46 | 47 | // The application frontend 48 | module web './app/web-appservice-avm.bicep' = { 49 | name: 'web' 50 | scope: rg 51 | params: { 52 | name: !empty(webServiceName) ? webServiceName : '${abbrs.webSitesAppService}web-${resourceToken}' 53 | location: location 54 | tags: tags 55 | appServicePlanId: appServicePlan.outputs.resourceId 56 | appInsightResourceId: monitoring.outputs.applicationInsightsResourceId 57 | linuxFxVersion: 'node|20-lts' 58 | } 59 | } 60 | 61 | // The application backend 62 | module api './app/api-appservice-avm.bicep' = { 63 | name: 'api' 64 | scope: rg 65 | params: { 66 | name: !empty(apiServiceName) ? apiServiceName : '${abbrs.webSitesAppService}api-${resourceToken}' 67 | location: location 68 | tags: tags 69 | kind: 'app' 70 | appServicePlanId: appServicePlan.outputs.resourceId 71 | siteConfig: { 72 | alwaysOn: true 73 | linuxFxVersion: 'dotnetcore|8.0' 74 | } 75 | appSettings: { 76 | AZURE_KEY_VAULT_ENDPOINT: keyVault.outputs.uri 77 | AZURE_COSMOS_CONNECTION_STRING_KEY: cosmos.outputs.connectionStringKey 78 | AZURE_COSMOS_DATABASE_NAME: cosmos.outputs.databaseName 79 | AZURE_COSMOS_ENDPOINT: cosmos.outputs.endpoint 80 | API_ALLOW_ORIGINS: web.outputs.SERVICE_WEB_URI 81 | SCM_DO_BUILD_DURING_DEPLOYMENT: false 82 | } 83 | appInsightResourceId: monitoring.outputs.applicationInsightsResourceId 84 | allowedOrigins: [ web.outputs.SERVICE_WEB_URI ] 85 | } 86 | } 87 | 88 | // Give the API access to KeyVault 89 | module accessKeyVault 'br/public:avm/res/key-vault/vault:0.5.1' = { 90 | name: 'accesskeyvault' 91 | scope: rg 92 | params: { 93 | name: keyVault.outputs.name 94 | enableRbacAuthorization: false 95 | enableVaultForDeployment: false 96 | enableVaultForTemplateDeployment: false 97 | enablePurgeProtection: false 98 | sku: 'standard' 99 | accessPolicies: [ 100 | { 101 | objectId: principalId 102 | permissions: { 103 | secrets: [ 'get', 'list' ] 104 | } 105 | } 106 | { 107 | objectId: api.outputs.SERVICE_API_IDENTITY_PRINCIPAL_ID 108 | permissions: { 109 | secrets: [ 'get', 'list' ] 110 | } 111 | } 112 | ] 113 | } 114 | } 115 | 116 | // The application database 117 | module cosmos './app/db-avm.bicep' = { 118 | name: 'cosmos' 119 | scope: rg 120 | params: { 121 | accountName: !empty(cosmosAccountName) ? cosmosAccountName : '${abbrs.documentDBDatabaseAccounts}${resourceToken}' 122 | location: location 123 | tags: tags 124 | keyVaultResourceId: keyVault.outputs.resourceId 125 | principalId: principalId 126 | backupPolicyType: 'Periodic' 127 | } 128 | } 129 | 130 | // Give the API the role to access Cosmos 131 | module apiCosmosSqlRoleAssign 'br/public:avm/res/document-db/database-account:0.5.5' = { 132 | name: 'api-cosmos-access' 133 | scope: rg 134 | params: { 135 | name: cosmos.outputs.accountName 136 | location: location 137 | sqlRoleAssignmentsPrincipalIds: [ api.outputs.SERVICE_API_IDENTITY_PRINCIPAL_ID ] 138 | sqlRoleDefinitions: [ 139 | { 140 | name: 'writer' 141 | } 142 | ] 143 | } 144 | } 145 | 146 | // Create an App Service Plan to group applications under the same payment plan and SKU 147 | module appServicePlan 'br/public:avm/res/web/serverfarm:0.1.1' = { 148 | name: 'appserviceplan' 149 | scope: rg 150 | params: { 151 | name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' 152 | sku: { 153 | name: 'B3' 154 | tier: 'Basic' 155 | } 156 | location: location 157 | tags: tags 158 | reserved: true 159 | kind: 'Linux' 160 | } 161 | } 162 | 163 | // Create a keyvault to store secrets 164 | module keyVault 'br/public:avm/res/key-vault/vault:0.5.1' = { 165 | name: 'keyvault' 166 | scope: rg 167 | params: { 168 | name: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' 169 | location: location 170 | tags: tags 171 | enableRbacAuthorization: false 172 | enableVaultForDeployment: false 173 | enableVaultForTemplateDeployment: false 174 | enablePurgeProtection: false 175 | sku: 'standard' 176 | } 177 | } 178 | 179 | // Monitor application with Azure Monitor 180 | module monitoring 'br/public:avm/ptn/azd/monitoring:0.1.0' = { 181 | name: 'monitoring' 182 | scope: rg 183 | params: { 184 | applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' 185 | logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 186 | applicationInsightsDashboardName: !empty(applicationInsightsDashboardName) ? applicationInsightsDashboardName : '${abbrs.portalDashboards}${resourceToken}' 187 | location: location 188 | tags: tags 189 | } 190 | } 191 | 192 | // Creates Azure API Management (APIM) service to mediate the requests between the frontend and the backend API 193 | module apim 'br/public:avm/res/api-management/service:0.2.0' = if (useAPIM) { 194 | name: 'apim-deployment' 195 | scope: rg 196 | params: { 197 | name: !empty(apimServiceName) ? apimServiceName : '${abbrs.apiManagementService}${resourceToken}' 198 | publisherEmail: 'noreply@microsoft.com' 199 | publisherName: 'n/a' 200 | location: location 201 | tags: tags 202 | sku: apimSku 203 | skuCount: 0 204 | zones: [] 205 | customProperties: {} 206 | loggers: [ 207 | { 208 | name: 'app-insights-logger' 209 | credentials: { 210 | instrumentationKey: monitoring.outputs.applicationInsightsInstrumentationKey 211 | } 212 | loggerDescription: 'Logger to Azure Application Insights' 213 | isBuffered: false 214 | loggerType: 'applicationInsights' 215 | targetResourceId: monitoring.outputs.applicationInsightsResourceId 216 | } 217 | ] 218 | } 219 | } 220 | 221 | //Configures the API settings for an api app within the Azure API Management (APIM) service. 222 | module apimApi 'br/public:avm/ptn/azd/apim-api:0.1.0' = if (useAPIM) { 223 | name: 'apim-api-deployment' 224 | scope: rg 225 | params: { 226 | apiBackendUrl: api.outputs.SERVICE_API_URI 227 | apiDescription: 'This is a simple Todo API' 228 | apiDisplayName: 'Simple Todo API' 229 | apiName: 'todo-api' 230 | apiPath: 'todo' 231 | name: useAPIM ? useAPIM ? apim.outputs.name : '' : '' 232 | webFrontendUrl: web.outputs.SERVICE_WEB_URI 233 | location: location 234 | apiAppName: api.outputs.SERVICE_API_NAME 235 | } 236 | } 237 | 238 | // Data outputs 239 | output AZURE_COSMOS_ENDPOINT string = cosmos.outputs.endpoint 240 | output AZURE_COSMOS_CONNECTION_STRING_KEY string = cosmos.outputs.connectionStringKey 241 | output AZURE_COSMOS_DATABASE_NAME string = cosmos.outputs.databaseName 242 | 243 | // App outputs 244 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString 245 | output AZURE_KEY_VAULT_ENDPOINT string = keyVault.outputs.uri 246 | output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name 247 | output AZURE_LOCATION string = location 248 | output AZURE_TENANT_ID string = tenant().tenantId 249 | output API_BASE_URL string = useAPIM ? apimApi.outputs.serviceApiUri : api.outputs.SERVICE_API_URI 250 | output REACT_APP_WEB_BASE_URL string = web.outputs.SERVICE_WEB_URI 251 | output USE_APIM bool = useAPIM 252 | output SERVICE_API_ENDPOINTS array = useAPIM ? [ apimApi.outputs.serviceApiUri, api.outputs.SERVICE_API_URI ]: [] 253 | -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "environmentName": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "principalId": { 12 | "value": "${AZURE_PRINCIPAL_ID}" 13 | }, 14 | "useAPIM": { 15 | "value": "${USE_APIM=false}" 16 | }, 17 | "apimSku": { 18 | "value": "${APIM_SKU=Consumption}" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: Simple Todo API 4 | version: 3.0.0 5 | title: Simple Todo API 6 | contact: 7 | email: azdevteam@microsoft.com 8 | 9 | components: 10 | schemas: 11 | TodoItem: 12 | type: object 13 | required: 14 | - listId 15 | - name 16 | - description 17 | description: A task that needs to be completed 18 | properties: 19 | id: 20 | type: string 21 | listId: 22 | type: string 23 | name: 24 | type: string 25 | description: 26 | type: string 27 | state: 28 | $ref: "#/components/schemas/TodoState" 29 | dueDate: 30 | type: string 31 | format: date-time 32 | completedDate: 33 | type: string 34 | format: date-time 35 | TodoList: 36 | type: object 37 | required: 38 | - name 39 | properties: 40 | id: 41 | type: string 42 | name: 43 | type: string 44 | description: 45 | type: string 46 | description: " A list of related Todo items" 47 | TodoState: 48 | type: string 49 | enum: 50 | - todo 51 | - inprogress 52 | - done 53 | parameters: 54 | listId: 55 | in: path 56 | required: true 57 | name: listId 58 | description: The Todo list unique identifier 59 | schema: 60 | type: string 61 | itemId: 62 | in: path 63 | required: true 64 | name: itemId 65 | description: The Todo item unique identifier 66 | schema: 67 | type: string 68 | state: 69 | in: path 70 | required: true 71 | name: state 72 | description: The Todo item state 73 | schema: 74 | $ref: "#/components/schemas/TodoState" 75 | top: 76 | in: query 77 | required: false 78 | name: top 79 | description: The max number of items to returns in a result 80 | schema: 81 | type: number 82 | default: 20 83 | skip: 84 | in: query 85 | required: false 86 | name: skip 87 | description: The number of items to skip within the results 88 | schema: 89 | type: number 90 | default: 0 91 | 92 | requestBodies: 93 | TodoList: 94 | description: The Todo List 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/TodoList" 99 | TodoItem: 100 | description: The Todo Item 101 | content: 102 | application/json: 103 | schema: 104 | $ref: "#/components/schemas/TodoItem" 105 | 106 | responses: 107 | TodoList: 108 | description: A Todo list result 109 | content: 110 | application/json: 111 | schema: 112 | $ref: "#/components/schemas/TodoList" 113 | TodoListArray: 114 | description: An array of Todo lists 115 | content: 116 | application/json: 117 | schema: 118 | type: array 119 | items: 120 | $ref: "#/components/schemas/TodoList" 121 | TodoItem: 122 | description: A Todo item result 123 | content: 124 | application/json: 125 | schema: 126 | $ref: "#/components/schemas/TodoItem" 127 | TodoItemArray: 128 | description: An array of Todo items 129 | content: 130 | application/json: 131 | schema: 132 | type: array 133 | items: 134 | $ref: "#/components/schemas/TodoItem" 135 | 136 | paths: 137 | /lists: 138 | get: 139 | operationId: GetLists 140 | summary: Gets an array of Todo lists 141 | tags: 142 | - Lists 143 | parameters: 144 | - $ref: "#/components/parameters/top" 145 | - $ref: "#/components/parameters/skip" 146 | responses: 147 | 200: 148 | $ref: "#/components/responses/TodoListArray" 149 | post: 150 | operationId: CreateList 151 | summary: Creates a new Todo list 152 | tags: 153 | - Lists 154 | requestBody: 155 | $ref: "#/components/requestBodies/TodoList" 156 | responses: 157 | 201: 158 | $ref: "#/components/responses/TodoList" 159 | 400: 160 | description: Invalid request schema 161 | /lists/{listId}: 162 | get: 163 | operationId: GetListById 164 | summary: Gets a Todo list by unique identifier 165 | tags: 166 | - Lists 167 | parameters: 168 | - $ref: "#/components/parameters/listId" 169 | responses: 170 | 200: 171 | $ref: "#/components/responses/TodoList" 172 | 404: 173 | description: Todo list not found 174 | put: 175 | operationId: UpdateListById 176 | summary: Updates a Todo list by unique identifier 177 | tags: 178 | - Lists 179 | requestBody: 180 | $ref: "#/components/requestBodies/TodoList" 181 | parameters: 182 | - $ref: "#/components/parameters/listId" 183 | responses: 184 | 200: 185 | $ref: "#/components/responses/TodoList" 186 | 404: 187 | description: Todo list not found 188 | 400: 189 | description: Todo list is invalid 190 | delete: 191 | operationId: DeleteListById 192 | summary: Deletes a Todo list by unique identifier 193 | tags: 194 | - Lists 195 | parameters: 196 | - $ref: "#/components/parameters/listId" 197 | responses: 198 | 204: 199 | description: Todo list deleted successfully 200 | 404: 201 | description: Todo list not found 202 | /lists/{listId}/items: 203 | post: 204 | operationId: CreateItem 205 | summary: Creates a new Todo item within a list 206 | tags: 207 | - Items 208 | requestBody: 209 | $ref: "#/components/requestBodies/TodoItem" 210 | parameters: 211 | - $ref: "#/components/parameters/listId" 212 | responses: 213 | 201: 214 | $ref: "#/components/responses/TodoItem" 215 | 404: 216 | description: Todo list not found 217 | get: 218 | operationId: GetItemsByListId 219 | summary: Gets Todo items within the specified list 220 | tags: 221 | - Items 222 | parameters: 223 | - $ref: "#/components/parameters/listId" 224 | - $ref: "#/components/parameters/top" 225 | - $ref: "#/components/parameters/skip" 226 | responses: 227 | 200: 228 | $ref: "#/components/responses/TodoItemArray" 229 | 404: 230 | description: Todo list not found 231 | /lists/{listId}/items/{itemId}: 232 | get: 233 | operationId: GetItemById 234 | summary: Gets a Todo item by unique identifier 235 | tags: 236 | - Items 237 | parameters: 238 | - $ref: "#/components/parameters/listId" 239 | - $ref: "#/components/parameters/itemId" 240 | responses: 241 | 200: 242 | $ref: "#/components/responses/TodoItem" 243 | 404: 244 | description: Todo list or item not found 245 | put: 246 | operationId: UpdateItemById 247 | summary: Updates a Todo item by unique identifier 248 | tags: 249 | - Items 250 | requestBody: 251 | $ref: "#/components/requestBodies/TodoItem" 252 | parameters: 253 | - $ref: "#/components/parameters/listId" 254 | - $ref: "#/components/parameters/itemId" 255 | responses: 256 | 200: 257 | $ref: "#/components/responses/TodoItem" 258 | 400: 259 | description: Todo item is invalid 260 | 404: 261 | description: Todo list or item not found 262 | delete: 263 | operationId: DeleteItemById 264 | summary: Deletes a Todo item by unique identifier 265 | tags: 266 | - Items 267 | parameters: 268 | - $ref: "#/components/parameters/listId" 269 | - $ref: "#/components/parameters/itemId" 270 | responses: 271 | 204: 272 | description: Todo item deleted successfully 273 | 404: 274 | description: Todo list or item not found 275 | /lists/{listId}/items/state/{state}: 276 | get: 277 | operationId: GetItemsByListIdAndState 278 | summary: Gets a list of Todo items of a specific state 279 | tags: 280 | - Items 281 | parameters: 282 | - $ref: "#/components/parameters/listId" 283 | - $ref: "#/components/parameters/state" 284 | - $ref: "#/components/parameters/top" 285 | - $ref: "#/components/parameters/skip" 286 | responses: 287 | 200: 288 | $ref: "#/components/responses/TodoItemArray" 289 | 404: 290 | description: Todo list or item not found 291 | put: 292 | operationId: UpdateItemsStateByListId 293 | summary: Changes the state of the specified list items 294 | tags: 295 | - Items 296 | requestBody: 297 | description: unique identifiers of the Todo items to update 298 | content: 299 | application/json: 300 | schema: 301 | type: array 302 | items: 303 | description: The Todo item unique identifier 304 | type: string 305 | parameters: 306 | - $ref: "#/components/parameters/listId" 307 | - $ref: "#/components/parameters/state" 308 | responses: 309 | 204: 310 | description: Todo items updated 311 | 400: 312 | description: Update request is invalid 313 | -------------------------------------------------------------------------------- /src/api/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml -------------------------------------------------------------------------------- /src/api/ListsRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Cosmos; 2 | using Microsoft.Azure.Cosmos.Linq; 3 | 4 | namespace SimpleTodo.Api; 5 | 6 | public class ListsRepository 7 | { 8 | private readonly Container _listsCollection; 9 | private readonly Container _itemsCollection; 10 | 11 | public ListsRepository(CosmosClient client, IConfiguration configuration) 12 | { 13 | var database = client.GetDatabase(configuration["AZURE_COSMOS_DATABASE_NAME"]); 14 | _listsCollection = database.GetContainer("TodoList"); 15 | _itemsCollection = database.GetContainer("TodoItem"); 16 | } 17 | 18 | public async Task> GetListsAsync(int? skip, int? batchSize) 19 | { 20 | return await ToListAsync( 21 | _listsCollection.GetItemLinqQueryable(), 22 | skip, 23 | batchSize); 24 | } 25 | 26 | public async Task GetListAsync(string listId) 27 | { 28 | var response = await _listsCollection.ReadItemAsync(listId, new PartitionKey(listId)); 29 | return response?.Resource; 30 | } 31 | 32 | public async Task DeleteListAsync(string listId) 33 | { 34 | await _listsCollection.DeleteItemAsync(listId, new PartitionKey(listId)); 35 | } 36 | 37 | public async Task AddListAsync(TodoList list) 38 | { 39 | list.Id = Guid.NewGuid().ToString("N"); 40 | await _listsCollection.UpsertItemAsync(list, new PartitionKey(list.Id)); 41 | } 42 | 43 | public async Task UpdateList(TodoList existingList) 44 | { 45 | await _listsCollection.ReplaceItemAsync(existingList, existingList.Id, new PartitionKey(existingList.Id)); 46 | } 47 | 48 | public async Task> GetListItemsAsync(string listId, int? skip, int? batchSize) 49 | { 50 | return await ToListAsync( 51 | _itemsCollection.GetItemLinqQueryable().Where(i => i.ListId == listId), 52 | skip, 53 | batchSize); 54 | } 55 | 56 | public async Task> GetListItemsByStateAsync(string listId, string state, int? skip, int? batchSize) 57 | { 58 | return await ToListAsync( 59 | _itemsCollection.GetItemLinqQueryable().Where(i => i.ListId == listId && i.State == state), 60 | skip, 61 | batchSize); 62 | } 63 | 64 | public async Task AddListItemAsync(TodoItem item) 65 | { 66 | item.Id = Guid.NewGuid().ToString("N"); 67 | await _itemsCollection.UpsertItemAsync(item, new PartitionKey(item.Id)); 68 | } 69 | 70 | public async Task GetListItemAsync(string listId, string itemId) 71 | { 72 | var response = await _itemsCollection.ReadItemAsync(itemId, new PartitionKey(itemId)); 73 | if (response?.Resource.ListId != listId) 74 | { 75 | return null; 76 | } 77 | return response.Resource; 78 | } 79 | 80 | public async Task DeleteListItemAsync(string listId, string itemId) 81 | { 82 | await _itemsCollection.DeleteItemAsync(itemId, new PartitionKey(itemId)); 83 | } 84 | 85 | public async Task UpdateListItem(TodoItem existingItem) 86 | { 87 | await _itemsCollection.ReplaceItemAsync(existingItem, existingItem.Id, new PartitionKey(existingItem.Id)); 88 | } 89 | 90 | private async Task> ToListAsync(IQueryable queryable, int? skip, int? batchSize) 91 | { 92 | if (skip != null) 93 | { 94 | queryable = queryable.Skip(skip.Value); 95 | } 96 | 97 | if (batchSize != null) 98 | { 99 | queryable = queryable.Take(batchSize.Value); 100 | } 101 | 102 | using FeedIterator iterator = queryable.ToFeedIterator(); 103 | var items = new List(); 104 | 105 | while (iterator.HasMoreResults) 106 | { 107 | foreach (var item in await iterator.ReadNextAsync()) 108 | { 109 | items.Add(item); 110 | } 111 | } 112 | 113 | return items; 114 | } 115 | } -------------------------------------------------------------------------------- /src/api/Program.cs: -------------------------------------------------------------------------------- 1 | using Azure.Identity; 2 | using Microsoft.Azure.Cosmos; 3 | using SimpleTodo.Api; 4 | 5 | var credential = new DefaultAzureCredential(); 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | builder.Services.AddSingleton(); 9 | builder.Services.AddSingleton(_ => new CosmosClient(builder.Configuration["AZURE_COSMOS_ENDPOINT"], credential, new CosmosClientOptions() 10 | { 11 | SerializerOptions = new CosmosSerializationOptions 12 | { 13 | PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase 14 | } 15 | })); 16 | builder.Services.AddCors(); 17 | builder.Services.AddApplicationInsightsTelemetry(builder.Configuration); 18 | builder.Services.AddEndpointsApiExplorer(); 19 | builder.Services.AddSwaggerGen(); 20 | var app = builder.Build(); 21 | 22 | app.UseCors(policy => 23 | { 24 | policy.AllowAnyOrigin(); 25 | policy.AllowAnyHeader(); 26 | policy.AllowAnyMethod(); 27 | }); 28 | 29 | // Swagger UI 30 | app.UseSwaggerUI(options => { 31 | options.SwaggerEndpoint("./openapi.yaml", "v1"); 32 | options.RoutePrefix = ""; 33 | }); 34 | 35 | app.UseStaticFiles(new StaticFileOptions{ 36 | // Serve openapi.yaml file 37 | ServeUnknownFileTypes = true, 38 | }); 39 | 40 | app.MapGroup("/lists") 41 | .MapTodoApi() 42 | .WithOpenApi(); 43 | app.Run(); -------------------------------------------------------------------------------- /src/api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:13087", 7 | "sslPort": 44375 8 | } 9 | }, 10 | "profiles": { 11 | "net": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": false, 14 | "launchBrowser": false, 15 | "applicationUrl": "https://localhost:3101;http://localhost:3100", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/Todo.Api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/api/Todo.Api.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.31903.286 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo.Api", "Todo.Api.csproj", "{A40339D0-DEF8-4CF7-9E57-CA227AA78056}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {A40339D0-DEF8-4CF7-9E57-CA227AA78056}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {A40339D0-DEF8-4CF7-9E57-CA227AA78056}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {A40339D0-DEF8-4CF7-9E57-CA227AA78056}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {A40339D0-DEF8-4CF7-9E57-CA227AA78056}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {9DE28BD5-F5D0-4A5F-98AC-5404AC5F2FC1} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /src/api/TodoEndpointsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http.HttpResults; 2 | 3 | namespace SimpleTodo.Api 4 | { 5 | public static class TodoEndpointsExtensions 6 | { 7 | public static RouteGroupBuilder MapTodoApi(this RouteGroupBuilder group) 8 | { 9 | group.MapGet("/", GetLists); 10 | group.MapPost("/", CreateList); 11 | group.MapGet("/{listId}", GetList); 12 | group.MapPut("/{listId}", UpdateList); 13 | group.MapDelete("/{listId}", DeleteList); 14 | group.MapGet("/{listId}/items", GetListItems); 15 | group.MapPost("/{listId}/items", CreateListItem); 16 | group.MapGet("/{listId}/items/{itemId}", GetListItem); 17 | group.MapPut("/{listId}/items/{itemId}", UpdateListItem); 18 | group.MapDelete("/{listId}/items/{itemId}", DeleteListItem); 19 | group.MapGet("/{listId}/state/{state}", GetListItemsByState); 20 | return group; 21 | } 22 | 23 | public static async Task>> GetLists(ListsRepository repository, int? skip = null, int? batchSize = null) 24 | { 25 | return TypedResults.Ok(await repository.GetListsAsync(skip, batchSize)); 26 | } 27 | 28 | public static async Task CreateList(ListsRepository repository, CreateUpdateTodoList list) 29 | { 30 | var todoList = new TodoList(list.name) 31 | { 32 | Description = list.description 33 | }; 34 | 35 | await repository.AddListAsync(todoList); 36 | 37 | return TypedResults.Created($"/lists/{todoList.Id}", todoList); 38 | } 39 | 40 | public static async Task GetList(ListsRepository repository, string listId) 41 | { 42 | var list = await repository.GetListAsync(listId); 43 | 44 | return list == null ? TypedResults.NotFound() : TypedResults.Ok(list); 45 | } 46 | 47 | public static async Task UpdateList(ListsRepository repository, string listId, CreateUpdateTodoList list) 48 | { 49 | var existingList = await repository.GetListAsync(listId); 50 | if (existingList == null) 51 | { 52 | return TypedResults.NotFound(); 53 | } 54 | 55 | existingList.Name = list.name; 56 | existingList.Description = list.description; 57 | existingList.UpdatedDate = DateTimeOffset.UtcNow; 58 | 59 | await repository.UpdateList(existingList); 60 | 61 | return TypedResults.Ok(existingList); 62 | } 63 | 64 | public static async Task DeleteList(ListsRepository repository, string listId) 65 | { 66 | if (await repository.GetListAsync(listId) == null) 67 | { 68 | return TypedResults.NotFound(); 69 | } 70 | 71 | await repository.DeleteListAsync(listId); 72 | 73 | return TypedResults.NoContent(); 74 | } 75 | 76 | public static async Task GetListItems(ListsRepository repository, string listId, int? skip = null, int? batchSize = null) 77 | { 78 | if (await repository.GetListAsync(listId) == null) 79 | { 80 | return TypedResults.NotFound(); 81 | } 82 | return TypedResults.Ok(await repository.GetListItemsAsync(listId, skip, batchSize)); 83 | } 84 | 85 | public static async Task CreateListItem(ListsRepository repository, string listId, CreateUpdateTodoItem item) 86 | { 87 | if (await repository.GetListAsync(listId) == null) 88 | { 89 | return TypedResults.NotFound(); 90 | } 91 | 92 | var newItem = new TodoItem(listId, item.name) 93 | { 94 | Name = item.name, 95 | Description = item.description, 96 | State = item.state, 97 | CreatedDate = DateTimeOffset.UtcNow 98 | }; 99 | 100 | await repository.AddListItemAsync(newItem); 101 | 102 | return TypedResults.Created($"/lists/{listId}/items{newItem.Id}", newItem); 103 | } 104 | 105 | public static async Task GetListItem(ListsRepository repository, string listId, string itemId) 106 | { 107 | if (await repository.GetListAsync(listId) == null) 108 | { 109 | return TypedResults.NotFound(); 110 | } 111 | 112 | var item = await repository.GetListItemAsync(listId, itemId); 113 | 114 | return item == null ? TypedResults.NotFound() : TypedResults.Ok(item); 115 | } 116 | 117 | public static async Task UpdateListItem(ListsRepository repository, string listId, string itemId, CreateUpdateTodoItem item) 118 | { 119 | var existingItem = await repository.GetListItemAsync(listId, itemId); 120 | if (existingItem == null) 121 | { 122 | return TypedResults.NotFound(); 123 | } 124 | 125 | existingItem.Name = item.name; 126 | existingItem.Description = item.description; 127 | existingItem.CompletedDate = item.completedDate; 128 | existingItem.DueDate = item.dueDate; 129 | existingItem.State = item.state; 130 | existingItem.UpdatedDate = DateTimeOffset.UtcNow; 131 | 132 | await repository.UpdateListItem(existingItem); 133 | 134 | return TypedResults.Ok(existingItem); 135 | } 136 | 137 | public static async Task DeleteListItem(ListsRepository repository, string listId, string itemId) 138 | { 139 | if (await repository.GetListItemAsync(listId, itemId) == null) 140 | { 141 | return TypedResults.NotFound(); 142 | } 143 | 144 | await repository.DeleteListItemAsync(listId, itemId); 145 | 146 | return TypedResults.NoContent(); 147 | } 148 | 149 | public static async Task GetListItemsByState(ListsRepository repository, string listId, string state, int? skip = null, int? batchSize = null) 150 | { 151 | if (await repository.GetListAsync(listId) == null) 152 | { 153 | return TypedResults.NotFound(); 154 | } 155 | 156 | return TypedResults.Ok(await repository.GetListItemsByStateAsync(listId, state, skip, batchSize)); 157 | } 158 | } 159 | 160 | public record CreateUpdateTodoList(string name, string? description = null); 161 | public record CreateUpdateTodoItem(string name, string state, DateTimeOffset? dueDate, DateTimeOffset? completedDate, string? description = null); 162 | } -------------------------------------------------------------------------------- /src/api/TodoItem.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleTodo.Api; 2 | 3 | public class TodoItem 4 | { 5 | public TodoItem(string listId, string name) 6 | { 7 | ListId = listId; 8 | Name = name; 9 | } 10 | 11 | public string? Id { get; set; } 12 | public string ListId { get; set; } 13 | public string Name { get; set; } 14 | public string? Description { get; set; } 15 | public string State { get; set; } = "todo"; 16 | public DateTimeOffset? DueDate { get; set; } 17 | public DateTimeOffset? CompletedDate { get; set; } 18 | public DateTimeOffset? CreatedDate { get; set; } = DateTimeOffset.UtcNow; 19 | public DateTimeOffset? UpdatedDate { get; set; } 20 | } -------------------------------------------------------------------------------- /src/api/TodoList.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleTodo.Api; 2 | 3 | public class TodoList 4 | { 5 | public TodoList(string name) 6 | { 7 | Name = name; 8 | } 9 | 10 | public string? Id { get; set; } 11 | public string Name { get; set; } 12 | public string? Description { get; set; } 13 | public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.UtcNow; 14 | public DateTimeOffset? UpdatedDate { get; set; } 15 | } -------------------------------------------------------------------------------- /src/api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /src/api/wwwroot/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/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-csharp-cosmos-sql/63b89b6dca0fdbc36bf758b816e537105e520c31/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 |