feat(centralui): Templates tree context menu — "New Instance…" (Deployer-gated)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user