Skip to content

Commit de0c063

Browse files
committed
feat(portal): Tenant CRUD (GraphQL + in-memory); DAB CRUD permissions; docs: one-command run + CRUD guide
1 parent f4e455c commit de0c063

File tree

8 files changed

+159
-8
lines changed

8 files changed

+159
-8
lines changed

management-portal/AppHost/AppHost.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
<ProjectReference Include="..\src\Portal\Portal.csproj" />
1111
</ItemGroup>
1212
<ItemGroup>
13-
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.0" />
13+
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.0" />
1414
</ItemGroup>
1515
</Project>

management-portal/README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
# Management Portal
22

3-
Run locally with Aspire AppHost. Defaults to in-memory data;
4-
set DAB_GRAPHQL_URL to point to your Data API Builder GraphQL endpoint to use real data.
3+
Run locally with one command or via the AppHost. Defaults to in-memory data; set DAB_GRAPHQL_URL to point to your Data API Builder GraphQL endpoint to use real data.
54

6-
## Local Run
5+
## Local Run (one command)
6+
- Start: pwsh -File .\scripts\run-local.ps1
7+
- Stop: pwsh -File .\scripts\stop-local.ps1
8+
- Portal: http://localhost:8081
9+
- GraphQL: http://localhost:8082/graphql
10+
- Cosmos Emulator: https://localhost:8085
11+
12+
## Local Run (AppHost)
713
- Build AppHost: dotnet build management-portal/AppHost
814
- Run AppHost: dotnet run --project management-portal/AppHost
915
- Portal URL: http://localhost:8081
@@ -14,3 +20,7 @@ set DAB_GRAPHQL_URL to point to your Data API Builder GraphQL endpoint to use re
1420
## Switch Data Source
1521
- Leave DAB_GRAPHQL_URL empty to use in-memory data
1622
- Set DAB_GRAPHQL_URL to use GraphQL
23+
24+
## CRUD
25+
- Tenants page supports create/update/delete when GraphQL is enabled (DAB provides mutations).
26+
- For in-memory mode, operations affect only the running process.

management-portal/dab/dab-config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"connection-string": "@env('COSMOS_CONNECTION_STRING')"
1010
},
1111
"runtime": {
12-
"rest": { "enabled": false },
12+
"rest": { "enabled": true, "path": "/api" },
1313
"graphql": { "enabled": true, "path": "/graphql" },
1414
"cors": { "origins": ["*"], "allow-credentials": false }
1515
},
@@ -22,7 +22,7 @@
2222
"plural": "Tenants"
2323
}
2424
},
25-
"permissions": [ { "role": "anonymous", "actions": [ "read" ] } ]
25+
"permissions": [ { "role": "anonymous", "actions": [ "read", "create", "update", "delete" ] } ]
2626
},
2727
"Cell": {
2828
"source": "cells",

management-portal/src/Portal/Pages/Tenants.razor

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33

44
<h1>Tenants</h1>
55

6+
<EditForm Model="editModel" OnValidSubmit="OnSaveAsync">
7+
<DataAnnotationsValidator />
8+
<div class="form-grid">
9+
<input @bind="editModel.Id" placeholder="Id" />
10+
<input @bind="editModel.DisplayName" placeholder="Display Name" />
11+
<input @bind="editModel.Domain" placeholder="Domain" />
12+
<input @bind="editModel.Tier" placeholder="Tier" />
13+
<input @bind="editModel.Status" placeholder="Status" />
14+
<input @bind="editModel.CellId" placeholder="CellId" />
15+
</div>
16+
<button type="submit">Save</button>
17+
<button type="button" @onclick="NewAsync">New</button>
18+
</EditForm>
19+
620
@if (tenants is null)
721
{
822
<p>Loading...</p>
@@ -23,6 +37,10 @@ else
2337
<td>@t.Tier</td>
2438
<td>@t.Status</td>
2539
<td>@t.CellId</td>
40+
<td>
41+
<button @onclick="() => Edit(t)">Edit</button>
42+
<button @onclick="() => DeleteAsync(t)">Delete</button>
43+
</td>
2644
</tr>
2745
}
2846
</tbody>
@@ -31,8 +49,39 @@ else
3149

3250
@code {
3351
private IReadOnlyList<Stamps.ManagementPortal.Models.Tenant>? tenants;
52+
class TenantEdit
53+
{
54+
public string Id { get; set; } = string.Empty;
55+
public string DisplayName { get; set; } = string.Empty;
56+
public string Domain { get; set; } = string.Empty;
57+
public string Tier { get; set; } = string.Empty;
58+
public string Status { get; set; } = string.Empty;
59+
public string CellId { get; set; } = string.Empty;
60+
}
61+
62+
private TenantEdit editModel = new();
3463
protected override async Task OnInitializedAsync()
3564
{
3665
tenants = await Data.GetTenantsAsync();
3766
}
67+
68+
void Edit(Stamps.ManagementPortal.Models.Tenant t)
69+
=> editModel = new TenantEdit { Id = t.Id, DisplayName = t.DisplayName, Domain = t.Domain, Tier = t.Tier, Status = t.Status, CellId = t.CellId };
70+
Task NewAsync() { editModel = new TenantEdit(); return Task.CompletedTask; }
71+
72+
async Task OnSaveAsync()
73+
{
74+
var rec = new Stamps.ManagementPortal.Models.Tenant(editModel.Id, editModel.DisplayName, editModel.Domain, editModel.Tier, editModel.Status, editModel.CellId);
75+
if (tenants?.Any(x => x.Id == rec.Id) == true)
76+
await Data.UpdateTenantAsync(rec);
77+
else
78+
await Data.CreateTenantAsync(rec);
79+
tenants = await Data.GetTenantsAsync();
80+
}
81+
82+
async Task DeleteAsync(Stamps.ManagementPortal.Models.Tenant t)
83+
{
84+
await Data.DeleteTenantAsync(t.Id, t.Id);
85+
tenants = await Data.GetTenantsAsync();
86+
}
3887
}

management-portal/src/Portal/Services/GraphQLDataService.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,56 @@ public async Task<IReadOnlyList<Cell>> GetCellsAsync(CancellationToken ct = defa
2121
public async Task<IReadOnlyList<Operation>> GetOperationsAsync(CancellationToken ct = default)
2222
=> await QueryAsync<Operation>("query { Operations { id tenantId type status createdAt } }", "Operations", ct);
2323

24+
public async Task<Tenant> CreateTenantAsync(Tenant tenant, CancellationToken ct = default)
25+
{
26+
var mutation = @"mutation($input: CreateTenantInput!) {
27+
createTenant(input: $input) { id displayName domain tier status cellId }
28+
}";
29+
var variables = new
30+
{
31+
input = new
32+
{
33+
id = tenant.Id,
34+
pk = tenant.Id,
35+
displayName = tenant.DisplayName,
36+
domain = tenant.Domain,
37+
tier = tenant.Tier,
38+
status = tenant.Status,
39+
cellId = tenant.CellId
40+
}
41+
};
42+
return await MutationAsync<Tenant>(mutation, variables, "createTenant", ct);
43+
}
44+
45+
public async Task<Tenant> UpdateTenantAsync(Tenant tenant, CancellationToken ct = default)
46+
{
47+
var mutation = @"mutation($id: ID!, $input: UpdateTenantInput!) {
48+
updateTenant(id: $id, input: $input) { id displayName domain tier status cellId }
49+
}";
50+
var variables = new
51+
{
52+
id = tenant.Id,
53+
input = new
54+
{
55+
displayName = tenant.DisplayName,
56+
domain = tenant.Domain,
57+
tier = tenant.Tier,
58+
status = tenant.Status,
59+
cellId = tenant.CellId
60+
}
61+
};
62+
return await MutationAsync<Tenant>(mutation, variables, "updateTenant", ct);
63+
}
64+
65+
public async Task DeleteTenantAsync(string id, string partitionKey, CancellationToken ct = default)
66+
{
67+
var mutation = @"mutation($id: ID!, $pk: String!) {
68+
deleteTenant(id: $id, partitionKeyValue: $pk)
69+
}";
70+
var variables = new { id, pk = partitionKey };
71+
await MutationAsync<object>(mutation, variables, "deleteTenant", ct);
72+
}
73+
2474
private async Task<IReadOnlyList<T>> QueryAsync<T>(string query, string rootField, CancellationToken ct)
2575
{
2676
var payload = new { query };
@@ -41,4 +91,21 @@ private async Task<IReadOnlyList<T>> QueryAsync<T>(string query, string rootFiel
4191
}
4292
return list;
4393
}
94+
95+
private async Task<T> MutationAsync<T>(string query, object variables, string rootField, CancellationToken ct)
96+
{
97+
var payload = new { query, variables };
98+
using var req = new HttpRequestMessage(HttpMethod.Post, "")
99+
{
100+
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
101+
};
102+
using var res = await Client.SendAsync(req, ct);
103+
res.EnsureSuccessStatusCode();
104+
using var stream = await res.Content.ReadAsStreamAsync(ct);
105+
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
106+
var data = doc.RootElement.GetProperty("data").GetProperty(rootField);
107+
if (typeof(T) == typeof(object)) return default!;
108+
var result = data.Deserialize<T>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
109+
return result!;
110+
}
44111
}

management-portal/src/Portal/Services/IDataService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@ public interface IDataService
77
Task<IReadOnlyList<Tenant>> GetTenantsAsync(CancellationToken ct = default);
88
Task<IReadOnlyList<Cell>> GetCellsAsync(CancellationToken ct = default);
99
Task<IReadOnlyList<Operation>> GetOperationsAsync(CancellationToken ct = default);
10+
11+
// Tenant CRUD
12+
Task<Tenant> CreateTenantAsync(Tenant tenant, CancellationToken ct = default);
13+
Task<Tenant> UpdateTenantAsync(Tenant tenant, CancellationToken ct = default);
14+
Task DeleteTenantAsync(string id, string partitionKey, CancellationToken ct = default);
1015
}

management-portal/src/Portal/Services/InMemoryDataService.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ namespace Stamps.ManagementPortal.Services;
44

55
public class InMemoryDataService : IDataService
66
{
7-
private static readonly IReadOnlyList<Tenant> Tenants = new List<Tenant>
7+
private static readonly List<Tenant> Tenants = new()
88
{
99
new("contoso","Contoso","contoso.com","enterprise","active","cell-eastus-1"),
1010
new("fabrikam","Fabrikam","fabrikam.io","smb","active","cell-westus-1")
@@ -20,7 +20,26 @@ public class InMemoryDataService : IDataService
2020
new("op-002","fabrikam","suspend","completed", DateTimeOffset.UtcNow.AddDays(-1))
2121
};
2222

23-
public Task<IReadOnlyList<Tenant>> GetTenantsAsync(CancellationToken ct = default) => Task.FromResult(Tenants);
23+
public Task<IReadOnlyList<Tenant>> GetTenantsAsync(CancellationToken ct = default) => Task.FromResult((IReadOnlyList<Tenant>)Tenants.ToList());
2424
public Task<IReadOnlyList<Cell>> GetCellsAsync(CancellationToken ct = default) => Task.FromResult(Cells);
2525
public Task<IReadOnlyList<Operation>> GetOperationsAsync(CancellationToken ct = default) => Task.FromResult(Operations);
26+
27+
public Task<Tenant> CreateTenantAsync(Tenant tenant, CancellationToken ct = default)
28+
{
29+
Tenants.Add(tenant);
30+
return Task.FromResult(tenant);
31+
}
32+
33+
public Task<Tenant> UpdateTenantAsync(Tenant tenant, CancellationToken ct = default)
34+
{
35+
var idx = Tenants.FindIndex(t => t.Id == tenant.Id);
36+
if (idx >= 0) Tenants[idx] = tenant;
37+
return Task.FromResult(tenant);
38+
}
39+
40+
public Task DeleteTenantAsync(string id, string partitionKey, CancellationToken ct = default)
41+
{
42+
Tenants.RemoveAll(t => t.Id == id);
43+
return Task.CompletedTask;
44+
}
2645
}

management-portal/src/Portal/_Imports.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
@using Microsoft.JSInterop
77
@using Stamps.ManagementPortal
88
@using Stamps.ManagementPortal.Shared
9+
@using System.ComponentModel.DataAnnotations

0 commit comments

Comments
 (0)