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:
@@ -96,6 +96,61 @@ public class AreaService
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-parents an area within its site. `newParentAreaId == null` moves the area to the site root.
|
||||
/// Rejects: self-parent, descendant-parent (cycle), cross-site parent, name collision at new level.
|
||||
/// </summary>
|
||||
public async Task<Result<Area>> MoveAreaAsync(
|
||||
int areaId, int? newParentAreaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Area>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
if (newParentAreaId == areaId)
|
||||
return Result<Area>.Failure("An area cannot be its own parent.");
|
||||
|
||||
if (newParentAreaId.HasValue)
|
||||
{
|
||||
var newParent = await _repository.GetAreaByIdAsync(newParentAreaId.Value, cancellationToken);
|
||||
if (newParent == null)
|
||||
return Result<Area>.Failure($"Target parent area with ID {newParentAreaId.Value} not found.");
|
||||
if (newParent.SiteId != area.SiteId)
|
||||
return Result<Area>.Failure("Areas can only be moved within the same site.");
|
||||
}
|
||||
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
|
||||
// Cycle prevention: the new parent must not be a descendant of the area being moved.
|
||||
if (newParentAreaId.HasValue)
|
||||
{
|
||||
var descendants = GetDescendantAreaIds(areaId, siblings);
|
||||
if (descendants.Contains(newParentAreaId.Value))
|
||||
return Result<Area>.Failure(
|
||||
$"Cannot move area '{area.Name}' under one of its own descendants.");
|
||||
}
|
||||
|
||||
if (newParentAreaId == area.ParentAreaId)
|
||||
return Result<Area>.Success(area);
|
||||
|
||||
var collision = siblings.FirstOrDefault(a =>
|
||||
a.Id != areaId &&
|
||||
a.ParentAreaId == newParentAreaId &&
|
||||
string.Equals(a.Name, area.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (collision != null)
|
||||
return Result<Area>.Failure(
|
||||
$"An area named '{area.Name}' already exists at the target level.");
|
||||
|
||||
area.ParentAreaId = newParentAreaId;
|
||||
await _repository.UpdateAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Move", "Area", area.Id.ToString(), area.Name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an area. Blocked if instances are assigned to this area or any descendant area.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user