feat(ui/deployment): consolidate sites/areas/instances into Topology page

Single /deployment/topology page replaces /deployment/instances (legacy URL
preserved as a secondary @page directive) and the /admin/areas* CRUD pages.
TreeView with Site → Area → Instance, V1–V7 visual guide (bi-building /
bi-diagram-3 / bi-box), always-visible empty containers, search dim, F2
inline area rename, and right-click context menus per node kind (Add Area,
Move to Area…, lifecycle actions, etc.).

Adds AreaService.MoveAreaAsync with cycle prevention, same-site enforcement,
and name-collision check at the new parent. Instance rename intentionally
out of scope — UniqueName is the site-side actor identity, requires its own
design pass.
This commit is contained in:
Joseph Doherty
2026-05-11 22:03:55 -04:00
parent b2eddd9713
commit f3386d0278
18 changed files with 1857 additions and 1122 deletions

View File

@@ -116,6 +116,157 @@ public class AreaServiceTests
_repoMock.Verify(r => r.DeleteAreaAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task MoveArea_ToOtherArea_Succeeds()
{
// Move 'Leaf' from under 'A' to under 'B'.
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("B") { Id = 2, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var result = await _sut.MoveAreaAsync(3, 2, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.ParentAreaId);
_repoMock.Verify(r => r.UpdateAreaAsync(It.Is<Area>(a => a.Id == 3 && a.ParentAreaId == 2), It.IsAny<CancellationToken>()), Times.Once);
_auditMock.Verify(a => a.LogAsync("admin", "Move", "Area", "3", "Leaf", It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task MoveArea_ToSiteRoot_Succeeds()
{
// Move 'Leaf' from under 'A' to site root (null parent).
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var result = await _sut.MoveAreaAsync(3, null, "admin");
Assert.True(result.IsSuccess);
Assert.Null(result.Value.ParentAreaId);
}
[Fact]
public async Task MoveArea_ToSelf_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
var result = await _sut.MoveAreaAsync(1, 1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("its own parent", result.Error);
}
[Fact]
public async Task MoveArea_ToDescendant_FailsWithCycleError()
{
// Tree: 1 -> 2 -> 3. Try to move 1 under 3.
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Root") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("Root") { Id = 1, SiteId = 1 },
new("Mid") { Id = 2, SiteId = 1, ParentAreaId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 }
});
var result = await _sut.MoveAreaAsync(1, 3, "admin");
Assert.True(result.IsFailure);
Assert.Contains("descendants", result.Error);
}
[Fact]
public async Task MoveArea_DifferentSite_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Foreign") { Id = 99, SiteId = 2 });
var result = await _sut.MoveAreaAsync(1, 99, "admin");
Assert.True(result.IsFailure);
Assert.Contains("same site", result.Error);
}
[Fact]
public async Task MoveArea_NameCollidesAtNewParent_Fails()
{
// 'Leaf' under parent 1; a sibling 'Leaf' already exists under parent 2.
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("B") { Id = 2, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 },
new("Leaf") { Id = 4, SiteId = 1, ParentAreaId = 2 }
});
var result = await _sut.MoveAreaAsync(3, 2, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task MoveArea_SameParent_NoOpSuccess()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
var result = await _sut.MoveAreaAsync(3, 1, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.UpdateAreaAsync(It.IsAny<Area>(), It.IsAny<CancellationToken>()), Times.Never);
_auditMock.Verify(a => a.LogAsync(It.IsAny<string>(), "Move", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task MoveArea_TargetParentMissing_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((Area?)null);
var result = await _sut.MoveAreaAsync(3, 999, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task DeleteArea_InstancesInDescendants_Blocked()
{