UnsTab native HTML5 drag/drop + 409 concurrent-edit modal + optimistic-concurrency commit path. Closes UI slice of task #153 (Phase 6.4 Stream A UI follow-up). Playwright E2E smoke is split into new task #199 — Playwright install + WebApplicationFactory + seeded-DB harness is genuinely its own infra-setup PR. Native HTML5 attributes (draggable, @ondragstart, @ondragover, @ondragleave, @ondrop) deliberately over MudBlazor per the task title — no MudBlazor ever joins this project. Two new service methods on UnsService land the data layer the existing UnsImpactAnalyzer assumed but which didn't actually exist: (1) LoadSnapshotAsync(generationId) — walks UnsAreas + UnsLines + per-line equipment counts + builds a UnsTreeSnapshot including a 16-char SHA-256 revision token computed deterministically over the sorted (kind, id, parent, name, notes) tuple-set so it's stable across processes + changes whenever any row is added / modified / deleted; (2) MoveLineAsync(generationId, expectedToken, lineId, targetAreaId) — re-parents one line inside the same draft under an EF transaction, recomputes the current revision token from freshly-loaded rows, and throws DraftRevisionConflictException when the caller-supplied token no longer matches. Token mismatch means another operator mutated the draft between preview + commit + the move rolls back rather than clobbering their work. No-op same-area drop is a silent return. Cross-generation move is prevented by the generationId filter on the transaction reads. UnsTab.razor gains draggable="true" on every line row with @ondragstart capturing the LineId into _dragLineId, and every area row is a drop target (@ondragover with :preventDefault so the browser accepts drops, @ondrop kicking off OnLineDroppedAsync). Drop path loads a fresh snapshot, builds a UnsMoveOperation(Kind=LineMove, source/target cluster matching because cross-cluster is decision-#82 rejected), runs UnsImpactAnalyzer.Analyze + shows a Bootstrap modal rendered inline in the component — modal shows HumanReadableSummary + equipment/tag counts + any CascadeWarnings list. Confirm button calls MoveLineAsync with the snapshot's RevisionToken; DraftRevisionConflictException surfaces a separate red-header "Draft changed — refresh required" modal with a Reload button that re-fetches areas + lines from the DB. New DraftRevisionConflictException in UnsService.cs, co-located with the service that throws it. Five new UnsServiceMoveTests covering LoadSnapshotAsync (areas + lines + equipment counts), RevisionToken stability between two reads, RevisionToken changes on AddLineAsync, MoveLineAsync happy path reparents the line in the DB, MoveLineAsync with stale token throws DraftRevisionConflictException + leaves the DB unchanged. Admin suite 81/81 passing (was 76, +5). Admin project builds 0 errors. Task #199 captures the deferred Playwright E2E smoke — drag a line onto a different area in a real browser, assert preview modal contents, click Confirm, assert the line row shows the new area. That PR stands up a new tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests project with Playwright + WebApplicationFactory + seeded InMemory DbContext.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db)
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft
|
||||
/// and counts equipment + tags per line. Returns the snapshot plus a deterministic
|
||||
/// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples —
|
||||
/// stable across processes + changes whenever any row is added / modified / deleted.
|
||||
/// </summary>
|
||||
public async Task<UnsTreeSnapshot> LoadSnapshotAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
var areas = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var equipmentCounts = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.GroupBy(e => e.UnsLineId)
|
||||
.Select(g => new { LineId = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var lineSummaries = lines.Select(l =>
|
||||
new UnsLineSummary(
|
||||
LineId: l.UnsLineId,
|
||||
Name: l.Name,
|
||||
EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId),
|
||||
TagCount: 0)).ToList();
|
||||
|
||||
var areaSummaries = areas.Select(a =>
|
||||
new UnsAreaSummary(
|
||||
AreaId: a.UnsAreaId,
|
||||
Name: a.Name,
|
||||
LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(l => l.UnsLineId).ToList())).ToList();
|
||||
|
||||
return new UnsTreeSnapshot
|
||||
{
|
||||
DraftGenerationId = generationId,
|
||||
RevisionToken = ComputeRevisionToken(areas, lines),
|
||||
Areas = areaSummaries,
|
||||
Lines = lineSummaries,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic re-parent of a line to a new area inside the same draft. The caller must pass
|
||||
/// the revision token it observed at preview time — a mismatch raises
|
||||
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
||||
/// modal instead of silently overwriting a peer's work.
|
||||
/// </summary>
|
||||
public async Task MoveLineAsync(
|
||||
long generationId,
|
||||
DraftRevisionToken expected,
|
||||
string lineId,
|
||||
string targetAreaId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expected);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lineId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId);
|
||||
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var areas = await db.UnsAreas
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var current = ComputeRevisionToken(areas, lines);
|
||||
if (!current.Matches(expected))
|
||||
throw new DraftRevisionConflictException(
|
||||
$"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " +
|
||||
"Refresh + redo the move.");
|
||||
|
||||
var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}.");
|
||||
|
||||
if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}.");
|
||||
|
||||
if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
return; // no-op drop — same area
|
||||
|
||||
line.UnsAreaId = targetAreaId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList<UnsArea> areas, IReadOnlyList<UnsLine> lines)
|
||||
{
|
||||
var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80);
|
||||
foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal))
|
||||
sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';');
|
||||
foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal))
|
||||
sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';');
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a UNS move's expected revision token no longer matches the live draft
|
||||
/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style
|
||||
/// "refresh required" modal in the Admin UI.</summary>
|
||||
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|
||||
|
||||
Reference in New Issue
Block a user