fix(centralui): Topology inline area rename commits the typed name on Enter (Gitea #38)

The rename input bound _renameBuffer with the default onchange trigger. A
browser fires change only after the Enter keydown (or on blur, which cancels
by design), so CommitRename always saw the original name and silently
no-oped: a UI rename never persisted. Present since the page was written
(pre-dates 513fa1aa); the Playwright test masked it by dispatching a
synthetic change event before Enter. Bind on oninput instead.

Tests: bUnit InlineRename_TypeThenEnter_PersistsNewName (fails on the old
binding); the Playwright rename test now types + Enter like an operator.
Also fix the Playwright OpenStableTopologyAsync race: it counted 'Load
instances' loaders immediately after clicking Expand all, before the
circuit re-render landed, and returned an unexpanded tree; it now waits
for no collapsed branch first.
This commit is contained in:
Joseph Doherty
2026-10-22 13:45:56 -04:00
parent 52a1e1b614
commit 560adefb0c
3 changed files with 41 additions and 6 deletions
@@ -618,6 +618,7 @@
aria-label="@($"Rename {node.Label}")"
@ref="_renameInput"
@bind="_renameBuffer"
@bind:event="oninput"
@onkeydown="(e) => OnRenameKeyDown(e, node)"
@onblur="() => CancelRename()" />
@if (!string.IsNullOrEmpty(_renameError))
@@ -755,6 +756,10 @@
_renameError = null;
}
// The buffer binds on `oninput` (Gitea #38): with the default `onchange` binding the
// browser's change event fires only AFTER the Enter keydown — so CommitRename saw the
// unchanged original name and silently cancelled — or on blur, which cancels by design.
// Binding per keystroke means Enter always commits what the operator typed.
private async Task OnRenameKeyDown(KeyboardEventArgs e, TopoNode node)
{
if (e.Key == "Escape")
@@ -139,13 +139,10 @@ public class TopologyAreaTests : IClassFixture<DeploymentFixture>
var input = page.Locator($"input[aria-label='Rename {original}']");
await Assertions.Expect(input).ToBeVisibleAsync();
// Type then Enter, exactly as an operator does. The input binds per keystroke
// (`oninput`, Gitea #38) — this test used to dispatch a synthetic `change` event
// first, which masked the bug that a real Enter never committed the typed name.
await input.FillAsync(renamed);
// The input is @bind="_renameBuffer" with the default onchange trigger, which a
// raw Enter keystroke does NOT fire (onchange only fires on blur in the browser) —
// and blur CANCELS the rename. So dispatch a `change` event to flush the bound
// value to the server WITHOUT losing focus, then press Enter to run CommitRename
// against the now-updated _renameBuffer.
await input.DispatchEventAsync("change");
await input.PressAsync("Enter"); // commit WITHOUT blurring (blur would cancel)
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
@@ -321,9 +318,15 @@ public class TopologyAreaTests : IClassFixture<DeploymentFixture>
if (await live.IsCheckedAsync()) await live.UncheckAsync(); // stop the 15s tree reload
// Lazy topology loads a bounded first page per branch. These small authoring
// fixtures need their sites/areas loaded before addressing labels or instances.
// "Expand all" re-renders over the SignalR circuit, so the click returns before the
// newly-expanded branches (and their "Load instances" loaders) exist. Wait until no
// collapsed branch remains before counting loaders — counting immediately raced the
// render and returned an unexpanded tree whenever the round-trip lost.
var collapsed = page.Locator("li[role='treeitem'][aria-expanded='false']");
for (var branch = 0; branch < 300; branch++)
{
await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
await Assertions.Expect(collapsed).ToHaveCountAsync(0);
var load = page.GetByRole(AriaRole.Button, new() { Name = "Load instances", Exact = true });
if (await load.CountAsync() == 0) return page;
var pages = page.GetByText("Page 1", new() { Exact = true });
@@ -449,6 +449,33 @@ public class TopologyPageTests : BunitContext
Assert.NotNull(cut.Find("input.form-control-sm.d-inline-block"));
}
/// <summary>
/// Gitea #38: typing a new name and pressing Enter must persist the rename. The buffer
/// used to bind on <c>onchange</c>, which a browser fires only after the Enter keydown
/// (or on blur, which cancels) — so the commit always saw the original name and no-oped.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InlineRename_TypeThenEnter_PersistsNewName()
{
var area = new Area("Line-1") { Id = 10, SiteId = 1 };
var areasBySite = new Dictionary<int, IReadOnlyList<Area>> { [1] = new List<Area> { area } };
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);
_repo.GetAreaByIdAsync(10, Arg.Any<CancellationToken>()).Returns(area);
var cut = RenderLoadedTopology();
FindToggleForLabel(cut, "Plant-A")!.Click();
cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1").DoubleClick();
var input = cut.Find("input[aria-label='Rename Line-1']");
// Keystroke-level binding: no change event (that only arrives after Enter, or on blur).
input.Input("Line-1-renamed");
await cut.Find("input[aria-label='Rename Line-1']").KeyDownAsync(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Enter" });
await _repo.Received(1).UpdateAreaAsync(
Arg.Is<Area>(a => a.Id == 10 && a.Name == "Line-1-renamed"), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that instance rows do not support double-click rename.</summary>
[Fact]
public void InstanceRows_DoNotHaveDoubleClickRename()