feat(centralui): Templates tree context menu — "New Instance…" (Deployer-gated)

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
Joseph Doherty
2026-08-21 07:44:15 -04:00
parent 5b4a27b23e
commit 7c2582d105
2 changed files with 71 additions and 6 deletions
@@ -130,10 +130,16 @@
private bool _loading = true;
private string? _errorMessage;
// "New Instance…" leaves the Design area for a RequireDeployment page; only offer
// it to users who hold that role so the menu never leads to a 403.
private bool _canCreateInstances;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_canCreateInstances = state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
await LoadTemplatesAsync();
}
@@ -302,6 +308,13 @@
{
<button class="dropdown-item" @onclick="() => OpenComposeDialog(tmpl)">Compose into…</button>
}
@if (_canCreateInstances)
{
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-test="ctx-new-instance"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?templateId={node.Id}")'>New Instance…</button>
<div class="dropdown-divider"></div>
}
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.Id, node.Name)">Move to Folder…</button>
<div class="dropdown-divider"></div>
@if (tmpl != null)
@@ -47,15 +47,16 @@ public class TemplatesPageTests : BunitContext
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
private void AddTestAuth()
private void AddTestAuth(params string[] roles)
{
// The page resolves the current user via the "Username" claim in
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Designer")
};
// Calling this again from a test replaces the constructor's Designer-only
// default (MS DI resolves the LAST registration of a service), which is how
// a test renders the page as a Deployer.
if (roles.Length == 0) roles = new[] { Roles.Designer };
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
var identity = new ClaimsIdentity(claims, "TestAuth");
var user = new ClaimsPrincipal(identity);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -396,6 +397,57 @@ public class TemplatesPageTests : BunitContext
Assert.EndsWith("/design/templates/create", nav.Uri);
}
// ---------------------------------------------------------------
// "New Instance…" context-menu item (Deployer-gated)
// ---------------------------------------------------------------
private void SeedOneTemplate(int id, string name)
{
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { new(name) { Id = id } }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
}
private static AngleSharp.Dom.IElement OpenTemplateContextMenu(
IRenderedComponent<TemplatesPage> cut, string label)
{
var row = cut.FindAll("li[role='treeitem']")
.First(li => li.TextContent.Contains(label))
.QuerySelector(".tv-row")!;
row.ContextMenu();
return cut.Find(".dropdown-menu.show");
}
[Fact]
public void TemplateContextMenu_OffersNewInstance_ForDeployer()
{
AddTestAuth(Roles.Designer, Roles.Deployer);
SeedOneTemplate(42, "Pump");
var cut = Render<TemplatesPage>();
var menu = OpenTemplateContextMenu(cut, "Pump");
var btn = menu.QuerySelector("button[data-test='ctx-new-instance']");
Assert.NotNull(btn);
Assert.Equal("New Instance…", btn!.TextContent.Trim());
btn.Click();
var nav = Services.GetRequiredService<NavigationManager>();
Assert.EndsWith("/deployment/instances/create?templateId=42", nav.Uri);
}
[Fact]
public void TemplateContextMenu_HidesNewInstance_ForDesignerOnly()
{
// The constructor already registered the Designer-only principal.
SeedOneTemplate(42, "Pump");
var cut = Render<TemplatesPage>();
var menu = OpenTemplateContextMenu(cut, "Pump");
Assert.Null(menu.QuerySelector("button[data-test='ctx-new-instance']"));
}
}
internal sealed class TestAuthStateProvider : AuthenticationStateProvider