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
@@ -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