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 bool _loading = true;
|
||||||
private string? _errorMessage;
|
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!;
|
private ToastNotification _toast = default!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
var state = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
_canCreateInstances = state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
|
||||||
await LoadTemplatesAsync();
|
await LoadTemplatesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +308,13 @@
|
|||||||
{
|
{
|
||||||
<button class="dropdown-item" @onclick="() => OpenComposeDialog(tmpl)">Compose into…</button>
|
<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>
|
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.Id, node.Name)">Move to Folder…</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
@if (tmpl != null)
|
@if (tmpl != null)
|
||||||
|
|||||||
@@ -47,15 +47,16 @@ public class TemplatesPageTests : BunitContext
|
|||||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
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
|
// The page resolves the current user via the "Username" claim in
|
||||||
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
|
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
|
||||||
var claims = new[]
|
// 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
|
||||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
// a test renders the page as a Deployer.
|
||||||
new Claim(JwtTokenService.RoleClaimType, "Designer")
|
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 identity = new ClaimsIdentity(claims, "TestAuth");
|
||||||
var user = new ClaimsPrincipal(identity);
|
var user = new ClaimsPrincipal(identity);
|
||||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
@@ -396,6 +397,57 @@ public class TemplatesPageTests : BunitContext
|
|||||||
Assert.EndsWith("/design/templates/create", nav.Uri);
|
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
|
internal sealed class TestAuthStateProvider : AuthenticationStateProvider
|
||||||
|
|||||||
Reference in New Issue
Block a user