├── .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 | 
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 | [](https://codespaces.new/azure-samples/todo-csharp-cosmos-sql)
26 | [](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 | 
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 | 
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 |
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 |
69 |
70 |
71 |
80 |
81 |
82 | );
83 | };
84 |
85 | export default TodoListMenu;
--------------------------------------------------------------------------------
/src/web/src/config/index.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | export interface ApiConfig {
4 | baseUrl: string
5 | }
6 |
7 | export interface ObservabilityConfig {
8 | connectionString: string
9 | }
10 |
11 | export interface AppConfig {
12 | api: ApiConfig
13 | observability: ObservabilityConfig
14 | }
15 |
16 | const config: AppConfig = {
17 | api: {
18 | baseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3100'
19 | },
20 | observability: {
21 | connectionString: import.meta.env.VITE_APPLICATIONINSIGHTS_CONNECTION_STRING || ''
22 | }
23 | }
24 |
25 | export default config;
--------------------------------------------------------------------------------
/src/web/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
4 | 'Droid Sans', 'Helvetica Neue', sans-serif;
5 | -webkit-font-smoothing: antialiased;
6 | -moz-osx-font-smoothing: grayscale;
7 | }
8 |
9 | code {
10 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
11 | }
12 |
--------------------------------------------------------------------------------
/src/web/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App.tsx';
4 | import { mergeStyles } from '@fluentui/react';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | // Inject some global styles
8 | mergeStyles({
9 | ':global(body,html,#root)': {
10 | margin: 0,
11 | padding: 0,
12 | height: '100vh',
13 | },
14 | });
15 |
16 | ReactDOM.createRoot(document.getElementById('root')!).render(
17 |
18 |
19 | ,
20 | )
21 |
22 | // If you want to start measuring performance in your app, pass a function
23 | // to log results (for example: reportWebVitals(console.log))
24 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
25 | reportWebVitals();
26 |
--------------------------------------------------------------------------------
/src/web/src/layout/header.tsx:
--------------------------------------------------------------------------------
1 | import { FontIcon, getTheme, IconButton, IIconProps, IStackStyles, mergeStyles, Persona, PersonaSize, Stack, Text } from '@fluentui/react';
2 | import { FC, ReactElement } from 'react';
3 |
4 | const theme = getTheme();
5 |
6 | const logoStyles: IStackStyles = {
7 | root: {
8 | width: '300px',
9 | background: theme.palette.themePrimary,
10 | alignItems: 'center',
11 | padding: '0 20px'
12 | }
13 | }
14 |
15 | const logoIconClass = mergeStyles({
16 | fontSize: 20,
17 | paddingRight: 10
18 | });
19 |
20 | const toolStackClass: IStackStyles = {
21 | root: {
22 | alignItems: 'center',
23 | height: 48,
24 | paddingRight: 10
25 | }
26 | }
27 |
28 | const iconProps: IIconProps = {
29 | styles: {
30 | root: {
31 | fontSize: 16,
32 | color: theme.palette.white
33 | }
34 | }
35 | }
36 |
37 | const Header: FC = (): ReactElement => {
38 | return (
39 |
40 |
41 |
42 | ToDo
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | {/* */}
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | export default Header;
--------------------------------------------------------------------------------
/src/web/src/layout/layout.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactElement, useContext, useEffect, useMemo } from 'react';
2 | import Header from './header';
3 | import Sidebar from './sidebar';
4 | import { Routes, Route, useNavigate } from 'react-router-dom';
5 | import HomePage from '../pages/homePage';
6 | import { Stack } from '@fluentui/react';
7 | import { AppContext } from '../models/applicationState';
8 | import { TodoContext } from '../components/todoContext';
9 | import * as itemActions from '../actions/itemActions';
10 | import * as listActions from '../actions/listActions';
11 | import { ListActions } from '../actions/listActions';
12 | import { ItemActions } from '../actions/itemActions';
13 | import { TodoItem, TodoList } from '../models';
14 | import { headerStackStyles, mainStackStyles, rootStackStyles, sidebarStackStyles } from '../ux/styles';
15 | import TodoItemDetailPane from '../components/todoItemDetailPane';
16 | import { bindActionCreators } from '../actions/actionCreators';
17 |
18 | const Layout: FC = (): ReactElement => {
19 | const navigate = useNavigate();
20 | const appContext = useContext(TodoContext)
21 | const actions = useMemo(() => ({
22 | lists: bindActionCreators(listActions, appContext.dispatch) as unknown as ListActions,
23 | items: bindActionCreators(itemActions, appContext.dispatch) as unknown as ItemActions,
24 | }), [appContext.dispatch]);
25 |
26 | // Load initial lists
27 | useEffect(() => {
28 | if (!appContext.state.lists) {
29 | actions.lists.list();
30 | }
31 | }, [actions.lists, appContext.state.lists]);
32 |
33 | const onListCreated = async (list: TodoList) => {
34 | const newList = await actions.lists.save(list);
35 | navigate(`/lists/${newList.id}`);
36 | }
37 |
38 | const onItemEdited = (item: TodoItem) => {
39 | actions.items.save(item.listId, item);
40 | actions.items.select(undefined);
41 | navigate(`/lists/${item.listId}`);
42 | }
43 |
44 | const onItemEditCancel = () => {
45 | if (appContext.state.selectedList) {
46 | actions.items.select(undefined);
47 | navigate(`/lists/${appContext.state.selectedList.id}`);
48 | }
49 | }
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
62 |
63 |
64 |
65 | } />
66 | } />
67 | } />
68 | } />
69 |
70 |
71 |
72 |
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | export default Layout;
83 |
--------------------------------------------------------------------------------
/src/web/src/layout/sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactElement } from 'react';
2 | import TodoListMenu from '../components/todoListMenu';
3 | import { TodoList } from '../models/todoList';
4 |
5 | interface SidebarProps {
6 | selectedList?: TodoList
7 | lists?: TodoList[];
8 | onListCreate: (list: TodoList) => void
9 | }
10 |
11 | const Sidebar: FC = (props: SidebarProps): ReactElement => {
12 | return (
13 |
14 |
18 |
19 | );
20 | };
21 |
22 | export default Sidebar;
--------------------------------------------------------------------------------
/src/web/src/models/applicationState.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch } from "react";
2 | import { TodoActions } from "../actions/common";
3 | import { TodoItem } from "./todoItem";
4 | import { TodoList } from "./todoList";
5 |
6 | export interface AppContext {
7 | state: ApplicationState
8 | dispatch: Dispatch
9 | }
10 |
11 | export interface ApplicationState {
12 | lists?: TodoList[]
13 | selectedList?: TodoList
14 | selectedItem?: TodoItem
15 | }
16 |
17 | export const getDefaultState = (): ApplicationState => {
18 | return {
19 | lists: undefined,
20 | selectedList: undefined,
21 | selectedItem: undefined
22 | }
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/src/web/src/models/index.ts:
--------------------------------------------------------------------------------
1 | export * from './todoItem';
2 | export * from './todoList';
--------------------------------------------------------------------------------
/src/web/src/models/todoItem.ts:
--------------------------------------------------------------------------------
1 | export enum TodoItemState {
2 | Todo = "todo",
3 | InProgress = "inprogress",
4 | Done = "done"
5 | }
6 |
7 | export interface TodoItem {
8 | id?: string
9 | listId: string
10 | name: string
11 | state: TodoItemState
12 | description?: string
13 | dueDate?: Date
14 | completedDate?:Date
15 | createdDate?: Date
16 | updatedDate?: Date
17 | }
--------------------------------------------------------------------------------
/src/web/src/models/todoList.ts:
--------------------------------------------------------------------------------
1 | import { TodoItem } from "./todoItem";
2 |
3 | export interface TodoList {
4 | id?: string
5 | name: string
6 | items?: TodoItem[]
7 | description?: string
8 | createdDate?: Date
9 | updatedDate?: Date
10 | }
--------------------------------------------------------------------------------
/src/web/src/pages/homePage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useContext, useMemo, useState, Fragment } from 'react';
2 | import { IconButton, IContextualMenuProps, IIconProps, Stack, Text, Shimmer, ShimmerElementType } from '@fluentui/react';
3 | import TodoItemListPane from '../components/todoItemListPane';
4 | import { TodoItem, TodoItemState } from '../models';
5 | import * as itemActions from '../actions/itemActions';
6 | import * as listActions from '../actions/listActions';
7 | import { TodoContext } from '../components/todoContext';
8 | import { AppContext } from '../models/applicationState';
9 | import { ItemActions } from '../actions/itemActions';
10 | import { ListActions } from '../actions/listActions';
11 | import { stackItemPadding, stackPadding, titleStackStyles } from '../ux/styles';
12 | import { useNavigate, useParams } from 'react-router-dom';
13 | import { bindActionCreators } from '../actions/actionCreators';
14 | import WithApplicationInsights from '../components/telemetryWithAppInsights.tsx';
15 |
16 | const HomePage = () => {
17 | const navigate = useNavigate();
18 | const appContext = useContext(TodoContext)
19 | const { listId, itemId } = useParams();
20 | const actions = useMemo(() => ({
21 | lists: bindActionCreators(listActions, appContext.dispatch) as unknown as ListActions,
22 | items: bindActionCreators(itemActions, appContext.dispatch) as unknown as ItemActions,
23 | }), [appContext.dispatch]);
24 |
25 | const [isReady, setIsReady] = useState(false)
26 |
27 | // Create default list of does not exist
28 | useEffect(() => {
29 | if (appContext.state.lists?.length === 0) {
30 | actions.lists.save({ name: 'My List' });
31 | }
32 | }, [actions.lists, appContext.state.lists?.length])
33 |
34 | // Select default list on initial load
35 | useEffect(() => {
36 | if (appContext.state.lists?.length && !listId && !appContext.state.selectedList) {
37 | const defaultList = appContext.state.lists[0];
38 | navigate(`/lists/${defaultList.id}`);
39 | }
40 | }, [appContext.state.lists, appContext.state.selectedList, listId, navigate])
41 |
42 | // React to selected list changes
43 | useEffect(() => {
44 | if (listId && appContext.state.selectedList?.id !== listId) {
45 | actions.lists.load(listId);
46 | }
47 | }, [actions.lists, appContext.state.selectedList, listId])
48 |
49 | // React to selected item change
50 | useEffect(() => {
51 | if (listId && itemId && appContext.state.selectedItem?.id !== itemId) {
52 | actions.items.load(listId, itemId);
53 | }
54 | }, [actions.items, appContext.state.selectedItem?.id, itemId, listId])
55 |
56 | // Load items for selected list
57 | useEffect(() => {
58 | if (appContext.state.selectedList?.id && !appContext.state.selectedList.items) {
59 | const loadListItems = async (listId: string) => {
60 | await actions.items.list(listId);
61 | setIsReady(true)
62 | }
63 |
64 | loadListItems(appContext.state.selectedList.id)
65 | }
66 | }, [actions.items, appContext.state.selectedList?.id, appContext.state.selectedList?.items])
67 |
68 | const onItemCreated = async (item: TodoItem) => {
69 | return await actions.items.save(item.listId, item);
70 | }
71 |
72 | const onItemCompleted = (item: TodoItem) => {
73 | item.state = TodoItemState.Done;
74 | item.completedDate = new Date();
75 | actions.items.save(item.listId, item);
76 | }
77 |
78 | const onItemSelected = (item?: TodoItem) => {
79 | actions.items.select(item);
80 | }
81 |
82 | const onItemDeleted = (item: TodoItem) => {
83 | if (item.id) {
84 | actions.items.remove(item.listId, item);
85 | navigate(`/lists/${item.listId}`);
86 | }
87 | }
88 |
89 | const deleteList = () => {
90 | if (appContext.state.selectedList?.id) {
91 | actions.lists.remove(appContext.state.selectedList.id);
92 | navigate('/lists');
93 | }
94 | }
95 |
96 | const iconProps: IIconProps = {
97 | iconName: 'More',
98 | styles: {
99 | root: {
100 | fontSize: 14
101 | }
102 | }
103 | }
104 |
105 | const menuProps: IContextualMenuProps = {
106 | items: [
107 | {
108 | key: 'delete',
109 | text: 'Delete List',
110 | iconProps: { iconName: 'Delete' },
111 | onClick: () => { deleteList() }
112 | }
113 | ]
114 | }
115 |
116 | return (
117 |
118 |
119 |
120 |
121 |
128 |
129 | {appContext.state.selectedList?.name}
130 | {appContext.state.selectedList?.description}
131 |
132 |
133 |
134 |
135 |
142 |
143 |
144 |
145 |
146 |
155 |
156 |
157 | );
158 | };
159 |
160 | const HomePageWithTelemetry = WithApplicationInsights(HomePage, 'HomePage');
161 |
162 | export default HomePageWithTelemetry;
163 |
--------------------------------------------------------------------------------
/src/web/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/web/src/reducers/index.ts:
--------------------------------------------------------------------------------
1 | import { Reducer } from "react";
2 | import { TodoActions } from "../actions/common";
3 | import { listsReducer } from "./listsReducer";
4 | import { selectedItemReducer } from "./selectedItemReducer";
5 | import { selectedListReducer } from "./selectedListReducer";
6 |
7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
8 | const combineReducers = (slices: {[key: string]: Reducer}) => (prevState: any, action: TodoActions) =>
9 | Object.keys(slices).reduce(
10 | (nextState, nextProp) => ({
11 | ...nextState,
12 | [nextProp]: slices[nextProp](prevState[nextProp], action)
13 | }),
14 | prevState
15 | );
16 |
17 | export default combineReducers({
18 | lists: listsReducer,
19 | selectedList: selectedListReducer,
20 | selectedItem: selectedItemReducer,
21 | });
22 |
--------------------------------------------------------------------------------
/src/web/src/reducers/listsReducer.ts:
--------------------------------------------------------------------------------
1 | import { Reducer } from "react";
2 | import { ActionTypes, TodoActions } from "../actions/common";
3 | import { TodoList } from "../models"
4 |
5 | export const listsReducer: Reducer = (state: TodoList[], action: TodoActions): TodoList[] => {
6 | switch (action.type) {
7 | case ActionTypes.LOAD_TODO_LISTS:
8 | state = [...action.payload];
9 | break;
10 | case ActionTypes.SAVE_TODO_LIST:
11 | state = [...state, action.payload];
12 | break;
13 | case ActionTypes.DELETE_TODO_LIST:
14 | state = [...state.filter(list => list.id !== action.payload)]
15 | }
16 |
17 | return state;
18 | }
--------------------------------------------------------------------------------
/src/web/src/reducers/selectedItemReducer.ts:
--------------------------------------------------------------------------------
1 | import { Reducer } from "react";
2 | import { ActionTypes, TodoActions } from "../actions/common";
3 | import { TodoItem } from "../models"
4 |
5 | export const selectedItemReducer: Reducer = (state: TodoItem | undefined, action: TodoActions): TodoItem | undefined => {
6 | switch (action.type) {
7 | case ActionTypes.SELECT_TODO_ITEM:
8 | case ActionTypes.LOAD_TODO_ITEM:
9 | state = action.payload ? { ...action.payload } : undefined;
10 | break;
11 | case ActionTypes.LOAD_TODO_LIST:
12 | state = undefined;
13 | break;
14 | case ActionTypes.DELETE_TODO_ITEM:
15 | if (state && state.id === action.payload) {
16 | state = undefined;
17 | }
18 | }
19 |
20 | return state;
21 | }
--------------------------------------------------------------------------------
/src/web/src/reducers/selectedListReducer.ts:
--------------------------------------------------------------------------------
1 | import { Reducer } from "react";
2 | import { ActionTypes, TodoActions } from "../actions/common";
3 | import { TodoList } from "../models"
4 |
5 | export const selectedListReducer: Reducer = (state: TodoList | undefined, action: TodoActions) => {
6 | switch (action.type) {
7 | case ActionTypes.SELECT_TODO_LIST:
8 | case ActionTypes.LOAD_TODO_LIST:
9 | state = action.payload ? { ...action.payload } : undefined;
10 | break;
11 | case ActionTypes.DELETE_TODO_LIST:
12 | if (state && state.id === action.payload) {
13 | state = undefined;
14 | }
15 | break;
16 | case ActionTypes.LOAD_TODO_ITEMS:
17 | if (state) {
18 | state.items = [...action.payload];
19 | }
20 | break;
21 | case ActionTypes.SAVE_TODO_ITEM:
22 | if (state) {
23 | const items = [...state.items || []];
24 | const index = items.findIndex(item => item.id === action.payload.id);
25 | if (index > -1) {
26 | items.splice(index, 1, action.payload);
27 | state.items = items;
28 | } else {
29 | state.items = [...items, action.payload];
30 | }
31 | }
32 | break;
33 | case ActionTypes.DELETE_TODO_ITEM:
34 | if (state) {
35 | state.items = [...(state.items || []).filter(item => item.id !== action.payload)];
36 | }
37 | break;
38 | }
39 |
40 | return state;
41 | }
--------------------------------------------------------------------------------
/src/web/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler): void => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/web/src/services/itemService.ts:
--------------------------------------------------------------------------------
1 | import { RestService } from './restService';
2 | import { TodoItem } from '../models';
3 |
4 | export class ItemService extends RestService {
5 | public constructor(baseUrl: string, baseRoute: string) {
6 | super(baseUrl, baseRoute);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/web/src/services/listService.ts:
--------------------------------------------------------------------------------
1 | import { RestService } from './restService';
2 | import { TodoList } from '../models';
3 |
4 | export class ListService extends RestService {
5 | public constructor(baseUrl: string, baseRoute: string) {
6 | super(baseUrl, baseRoute);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/web/src/services/restService.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance } from 'axios';
2 |
3 | export interface QueryOptions {
4 | top?: number;
5 | skip?: number;
6 | }
7 |
8 | export interface Entity {
9 | id?: string;
10 | created?: Date;
11 | updated?: Date
12 | }
13 |
14 | export abstract class RestService {
15 | protected client: AxiosInstance;
16 |
17 | public constructor(baseUrl: string, baseRoute: string) {
18 | this.client = axios.create({
19 | baseURL: `${baseUrl}${baseRoute}`
20 | });
21 | }
22 |
23 | public async getList(queryOptions?: QueryOptions): Promise {
24 | const response = await this.client.request({
25 | method: 'GET',
26 | data: queryOptions
27 | });
28 |
29 | return response.data;
30 | }
31 |
32 | public async get(id: string): Promise {
33 | const response = await this.client.request({
34 | method: 'GET',
35 | url: id
36 | });
37 |
38 | return response.data
39 | }
40 |
41 | public async save(entity: T): Promise {
42 | return entity.id
43 | ? await this.put(entity)
44 | : await this.post(entity);
45 | }
46 |
47 | public async delete(id: string): Promise {
48 | await this.client.request({
49 | method: 'DELETE',
50 | url: id
51 | });
52 | }
53 |
54 | private async post(entity: T): Promise {
55 | const response = await this.client.request({
56 | method: 'POST',
57 | data: entity
58 | });
59 |
60 | return response.data;
61 | }
62 |
63 | private async put(entity: T): Promise {
64 | const response = await this.client.request({
65 | method: 'PUT',
66 | url: entity.id,
67 | data: entity
68 | });
69 |
70 | return response.data;
71 | }
72 |
73 | public async patch(id: string, entity: Partial): Promise {
74 | const response = await this.client.request({
75 | method: 'PATCH',
76 | url: id,
77 | data: entity
78 | });
79 |
80 | return response.data;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/web/src/services/telemetryService.ts:
--------------------------------------------------------------------------------
1 | import { ReactPlugin } from "@microsoft/applicationinsights-react-js";
2 | import { ApplicationInsights, Snippet, ITelemetryItem } from "@microsoft/applicationinsights-web";
3 | import { DistributedTracingModes } from "@microsoft/applicationinsights-common";
4 | import { createBrowserHistory } from 'history'
5 | import config from "../config";
6 |
7 | const plugin = new ReactPlugin();
8 | let applicationInsights: ApplicationInsights;
9 | export const reactPlugin = plugin;
10 |
11 | export const getApplicationInsights = (): ApplicationInsights => {
12 | const browserHistory = createBrowserHistory({ window: window });
13 | if (applicationInsights) {
14 | return applicationInsights;
15 | }
16 |
17 | const ApplicationInsightsConfig: Snippet = {
18 | config: {
19 | connectionString: config.observability.connectionString,
20 | enableCorsCorrelation: true,
21 | distributedTracingMode: DistributedTracingModes.W3C,
22 | extensions: [plugin],
23 | extensionConfig: {
24 | [plugin.identifier]: { history: browserHistory }
25 | }
26 | }
27 | }
28 |
29 | applicationInsights = new ApplicationInsights(ApplicationInsightsConfig);
30 | try {
31 | applicationInsights.loadAppInsights();
32 | applicationInsights.addTelemetryInitializer((telemetry: ITelemetryItem) => {
33 | if (!telemetry) {
34 | return;
35 | }
36 | if (telemetry.tags) {
37 | telemetry.tags['ai.cloud.role'] = "webui";
38 | }
39 | });
40 | } catch(err) {
41 | // TODO - proper logging for web
42 | console.error("ApplicationInsights setup failed, ensure environment variable 'VITE_APPLICATIONINSIGHTS_CONNECTION_STRING' has been set.", err);
43 | }
44 |
45 | return applicationInsights;
46 | }
47 |
48 | export const trackEvent = (eventName: string, properties?: { [key: string]: unknown }): void => {
49 | if (!applicationInsights) {
50 | return;
51 | }
52 |
53 | applicationInsights.trackEvent({
54 | name: eventName,
55 | properties: properties
56 | });
57 | }
58 |
--------------------------------------------------------------------------------
/src/web/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/web/src/ux/styles.ts:
--------------------------------------------------------------------------------
1 | import { getTheme, IStackItemTokens, IStackStyles, IStackTokens } from '@fluentui/react'
2 | const theme = getTheme();
3 |
4 | export const rootStackStyles: IStackStyles = {
5 | root: {
6 | height: '100vh'
7 | }
8 | }
9 |
10 | export const headerStackStyles: IStackStyles = {
11 | root: {
12 | height: 48,
13 | background: theme.palette.themeDarker
14 | }
15 | }
16 |
17 | export const listItemsStackStyles: IStackStyles = {
18 | root: {
19 | padding: '10px'
20 | }
21 | }
22 |
23 | export const mainStackStyles: IStackStyles = {
24 | root: {
25 | }
26 | }
27 |
28 | export const sidebarStackStyles: IStackStyles = {
29 | root: {
30 | minWidth: 300,
31 | background: theme.palette.neutralPrimary,
32 | boxShadow: theme.effects.elevation8
33 | }
34 | }
35 |
36 | export const titleStackStyles: IStackStyles = {
37 | root: {
38 | alignItems: 'center',
39 | background: theme.palette.neutralPrimaryAlt,
40 | }
41 | }
42 |
43 | export const stackPadding: IStackTokens = {
44 | padding: 10
45 | }
46 |
47 | export const stackGaps: IStackTokens = {
48 | childrenGap: 10
49 | }
50 |
51 | export const stackItemPadding: IStackItemTokens = {
52 | padding: 10
53 | }
54 |
55 | export const stackItemMargin: IStackItemTokens = {
56 | margin: 10
57 | }
--------------------------------------------------------------------------------
/src/web/src/ux/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@fluentui/react';
2 |
3 | export const DarkTheme = createTheme({
4 | palette: {
5 | themePrimary: '#0392ff',
6 | themeLighterAlt: '#00060a',
7 | themeLighter: '#001729',
8 | themeLight: '#012c4d',
9 | themeTertiary: '#025799',
10 | themeSecondary: '#0280e0',
11 | themeDarkAlt: '#1c9dff',
12 | themeDark: '#3facff',
13 | themeDarker: '#72c2ff',
14 | neutralLighterAlt: '#323232',
15 | neutralLighter: '#3a3a3a',
16 | neutralLight: '#484848',
17 | neutralQuaternaryAlt: '#505050',
18 | neutralQuaternary: '#575757',
19 | neutralTertiaryAlt: '#747474',
20 | neutralTertiary: '#ececec',
21 | neutralSecondary: '#efefef',
22 | neutralPrimaryAlt: '#f2f2f2',
23 | neutralPrimary: '#e3e3e3',
24 | neutralDark: '#f9f9f9',
25 | black: '#fcfcfc',
26 | white: '#292929',
27 | }
28 | });
29 |
--------------------------------------------------------------------------------
/src/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/src/web/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/src/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/src/web/web.config:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/tests/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | playwright-report/
3 | test-results/
4 |
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 | # ToDo Application Tests
2 |
3 | The included [Playwright](https://playwright.dev/) smoke test will hit the ToDo app web endpoint, create, and delete an item.
4 |
5 | ## Run Tests
6 |
7 | The endpoint it hits will be discovered in this order:
8 |
9 | 1. Value of `REACT_APP_WEB_BASE_URL` environment variable
10 | 1. Value of `REACT_APP_WEB_BASE_URL` found in default .azure environment
11 | 1. Defaults to `http://localhost:3000`
12 |
13 | To run the tests:
14 |
15 | 1. CD to /tests
16 | 1. Run `npm i && npx playwright install`
17 | 1. Run `npx playwright test`
18 |
19 | You can use the `--headed` flag to open a browser when running the tests.
20 |
21 | ## Debug Tests
22 |
23 | Add the `--debug` flag to run with debugging enabled. You can find out more info here: https://playwright.dev/docs/next/test-cli#reference
24 |
25 | ```bash
26 | npx playwright test --debug
27 | ```
28 |
29 | More debugging references: https://playwright.dev/docs/debug and https://playwright.dev/docs/trace-viewer
--------------------------------------------------------------------------------
/tests/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tests",
3 | "lockfileVersion": 2,
4 | "requires": true,
5 | "packages": {
6 | "": {
7 | "devDependencies": {
8 | "@playwright/test": "^1.22.2",
9 | "@types/uuid": "^8.3.4",
10 | "dotenv": "^16.0.1",
11 | "uuid": "^8.3.2"
12 | }
13 | },
14 | "node_modules/@playwright/test": {
15 | "version": "1.22.2",
16 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.22.2.tgz",
17 | "integrity": "sha512-cCl96BEBGPtptFz7C2FOSN3PrTnJ3rPpENe+gYCMx4GNNDlN4tmo2D89y13feGKTMMAIVrXfSQ/UmaQKLy1XLA==",
18 | "dev": true,
19 | "dependencies": {
20 | "@types/node": "*",
21 | "playwright-core": "1.22.2"
22 | },
23 | "bin": {
24 | "playwright": "cli.js"
25 | },
26 | "engines": {
27 | "node": ">=14"
28 | }
29 | },
30 | "node_modules/@types/node": {
31 | "version": "17.0.38",
32 | "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.38.tgz",
33 | "integrity": "sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g==",
34 | "dev": true
35 | },
36 | "node_modules/@types/uuid": {
37 | "version": "8.3.4",
38 | "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz",
39 | "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
40 | "dev": true
41 | },
42 | "node_modules/dotenv": {
43 | "version": "16.0.1",
44 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.1.tgz",
45 | "integrity": "sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==",
46 | "dev": true,
47 | "engines": {
48 | "node": ">=12"
49 | }
50 | },
51 | "node_modules/playwright-core": {
52 | "version": "1.22.2",
53 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.22.2.tgz",
54 | "integrity": "sha512-w/hc/Ld0RM4pmsNeE6aL/fPNWw8BWit2tg+TfqJ3+p59c6s3B6C8mXvXrIPmfQEobkcFDc+4KirNzOQ+uBSP1Q==",
55 | "dev": true,
56 | "bin": {
57 | "playwright": "cli.js"
58 | },
59 | "engines": {
60 | "node": ">=14"
61 | }
62 | },
63 | "node_modules/uuid": {
64 | "version": "8.3.2",
65 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
66 | "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
67 | "dev": true,
68 | "bin": {
69 | "uuid": "dist/bin/uuid"
70 | }
71 | }
72 | },
73 | "dependencies": {
74 | "@playwright/test": {
75 | "version": "1.22.2",
76 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.22.2.tgz",
77 | "integrity": "sha512-cCl96BEBGPtptFz7C2FOSN3PrTnJ3rPpENe+gYCMx4GNNDlN4tmo2D89y13feGKTMMAIVrXfSQ/UmaQKLy1XLA==",
78 | "dev": true,
79 | "requires": {
80 | "@types/node": "*",
81 | "playwright-core": "1.22.2"
82 | }
83 | },
84 | "@types/node": {
85 | "version": "17.0.38",
86 | "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.38.tgz",
87 | "integrity": "sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g==",
88 | "dev": true
89 | },
90 | "@types/uuid": {
91 | "version": "8.3.4",
92 | "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz",
93 | "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
94 | "dev": true
95 | },
96 | "dotenv": {
97 | "version": "16.0.1",
98 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.1.tgz",
99 | "integrity": "sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==",
100 | "dev": true
101 | },
102 | "playwright-core": {
103 | "version": "1.22.2",
104 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.22.2.tgz",
105 | "integrity": "sha512-w/hc/Ld0RM4pmsNeE6aL/fPNWw8BWit2tg+TfqJ3+p59c6s3B6C8mXvXrIPmfQEobkcFDc+4KirNzOQ+uBSP1Q==",
106 | "dev": true
107 | },
108 | "uuid": {
109 | "version": "8.3.2",
110 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
111 | "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
112 | "dev": true
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/tests/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "@playwright/test": "^1.22.2",
4 | "@types/uuid": "^8.3.4",
5 | "dotenv": "^16.0.1",
6 | "uuid": "^8.3.2"
7 | }
8 | }
--------------------------------------------------------------------------------
/tests/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { PlaywrightTestConfig } from "@playwright/test";
2 | import devices from "@playwright/test";
3 | import fs from "fs";
4 | import { join } from "path";
5 | import dotenv from "dotenv";
6 |
7 | /**
8 | * See https://playwright.dev/docs/test-configuration.
9 | */
10 | const config: PlaywrightTestConfig = {
11 | testDir: ".",
12 | /* Maximum time one test can run for. Using 2 hours per test */
13 | timeout: 2 * 60 * 60 * 1000,
14 | expect: {
15 | /**
16 | * Maximum time expect() should wait for the condition to be met.
17 | * For example in `await expect(locator).toHaveText();`
18 | */
19 | timeout: 60 * 60 * 1000,
20 | },
21 | /* Fail the build on CI if you accidentally left test.only in the source code. */
22 | forbidOnly: !!process.env.CI,
23 | /* Retry on CI only */
24 | retries: process.env.CI ? 2 : 0,
25 | /* Opt out of parallel tests on CI. */
26 | workers: process.env.CI ? 1 : undefined,
27 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */
28 | reporter: "html",
29 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
30 | use: {
31 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
32 | actionTimeout: 0,
33 | /* Base URL to use in actions like `await page.goto('/')`. */
34 | baseURL: getBaseURL(),
35 |
36 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
37 | trace: "on-first-retry",
38 | },
39 |
40 | /* Configure projects for major browsers */
41 | projects: [
42 | {
43 | name: "chromium",
44 | use: {
45 | ...devices["Desktop Chrome"],
46 | },
47 | },
48 | ],
49 | };
50 |
51 | function getBaseURL() {
52 | // If we don't have URL and aren't in CI, then try to load from environment
53 | if (!process.env.REACT_APP_WEB_BASE_URL && !process.env.CI) {
54 | // Try to get env in .azure folder
55 | let environment = process.env.AZURE_ENV_NAME;
56 | if (!environment) {
57 | // Couldn't find env name in env var, let's try to load from .azure folder
58 | try {
59 | let configfilePath = join(__dirname, "..", ".azure", "config.json");
60 | if (fs.existsSync(configfilePath)) {
61 | let configFile = JSON.parse(fs.readFileSync(configfilePath, "utf-8"));
62 | environment = configFile["defaultEnvironment"];
63 | }
64 | } catch (err) {
65 | console.log("Unable to load default environment: " + err);
66 | }
67 | }
68 |
69 | if (environment) {
70 | let envPath = join(__dirname, "..", ".azure", environment, ".env");
71 | console.log("Loading env from: " + envPath);
72 | dotenv.config({ path: envPath });
73 | return process.env.REACT_APP_WEB_BASE_URL;
74 | }
75 | }
76 |
77 | let baseURL = process.env.REACT_APP_WEB_BASE_URL || "http://localhost:3000";
78 | console.log("baseUrl: " + baseURL);
79 | return baseURL;
80 | }
81 |
82 | export default config;
83 |
--------------------------------------------------------------------------------
/tests/todo.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 | import { v4 as uuidv4 } from "uuid";
3 |
4 | test("Create and delete item test", async ({ page }) => {
5 | await page.goto("/", { waitUntil: 'networkidle' });
6 |
7 | await expect(page.locator("text=My List").first()).toBeVisible();
8 |
9 | await expect(page.locator("text=This list is empty.").first()).toBeVisible()
10 |
11 | const guid = uuidv4();
12 | console.log(`Creating item with text: ${guid}`);
13 |
14 | await page.locator('[placeholder="Add an item"]').focus();
15 | await page.locator('[placeholder="Add an item"]').type(guid);
16 | await page.locator('[placeholder="Add an item"]').press("Enter");
17 |
18 | console.log(`Deleting item with text: ${guid}`);
19 | await expect(page.locator(`text=${guid}`).first()).toBeVisible()
20 |
21 | await page.locator(`text=${guid}`).click();
22 |
23 | /* when delete option is hide behind "..." button */
24 | const itemMoreDeleteButton = await page.$('button[role="menuitem"]:has-text("")');
25 | if(itemMoreDeleteButton){
26 | await itemMoreDeleteButton.click();
27 | };
28 | await page.locator('button[role="menuitem"]:has-text("Delete")').click();
29 |
30 | await expect(page.locator(`text=${guid}`).first()).toBeHidden()
31 | });
32 |
--------------------------------------------------------------------------------