diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor index 9a865a82d..64f58a4c7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor @@ -74,7 +74,7 @@ - Row + Row Name Group Verdict @@ -89,7 +89,16 @@ @(row.Ok ? "✓" : "✕") @row.RowNumber - @row.Name + + @if (row.Name.Length == 0) + { + (blank name) + } + else + { + @row.Name + } + @(row.EffectiveGroupPath.Length == 0 ? "(device root)" : row.EffectiveGroupPath) @if (row.Ok) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs index 8ddb93541..a4dc9acf9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -128,19 +128,24 @@ public static class RawTagCsvMapper var typedUsed = new List(); var overrodeFallback = new List(); + // Echoed onto an INVALID row too (lmxopcua#847): the grid's Name/Group columns used to render blank for + // a rejected row, so the operator could not tell which tag failed. The raw cell is shown as typed. + var rawName = Cell("Name"); + var rawGroup = CombineGroupPath(groupPathPrefix, Cell("TagGroupPath")) ?? ""; + try { // --- Common identity/access fields --- var name = Cell("Name"); if (RawPaths.ValidateSegment(name) is { } nameErr) { - return Invalid(rowNumber, cells, $"Name: {nameErr}"); + return Invalid(rowNumber, cells, rawName, rawGroup, $"Name: {nameErr}"); } var dataType = Cell("DataType"); if (dataType.Length == 0) { - return Invalid(rowNumber, cells, "DataType is required."); + return Invalid(rowNumber, cells, rawName, rawGroup, "DataType is required."); } var accessRaw = Cell("AccessLevel"); @@ -161,7 +166,7 @@ public static class RawTagCsvMapper var baseKeys = ReadRootKeys(baseJson.Length == 0 ? "{}" : baseJson, out var baseValid); if (!baseValid) { - return Invalid(rowNumber, cells, "TagConfigJson is not well-formed JSON."); + return Invalid(rowNumber, cells, rawName, rawGroup, "TagConfigJson is not well-formed JSON."); } if (map is not null) @@ -219,7 +224,7 @@ public static class RawTagCsvMapper var n = CsvValues.ParseInt("ArrayLength", arrayLenRaw); if (n < 0) { - return Invalid(rowNumber, cells, "ArrayLength must not be negative."); + return Invalid(rowNumber, cells, rawName, rawGroup, "ArrayLength must not be negative."); } arrayLength = (uint)n; @@ -243,14 +248,14 @@ public static class RawTagCsvMapper // claim the tag is already present. if (TagConfigValidator.Validate(driverType, json) is { } configError) { - return Invalid(rowNumber, cells, $"TagConfig: {configError}"); + return Invalid(rowNumber, cells, rawName, rawGroup, $"TagConfig: {configError}"); } // The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit // (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK. if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError) { - return Invalid(rowNumber, cells, rowError); + return Invalid(rowNumber, cells, rawName, rawGroup, rowError); } var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json)); @@ -271,13 +276,15 @@ public static class RawTagCsvMapper } catch (FormatException ex) { - return Invalid(rowNumber, cells, ex.Message); + return Invalid(rowNumber, cells, rawName, rawGroup, ex.Message); } } - private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string error) => new() + private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string rawName, string rawGroup, string error) => new() { RowNumber = rowNumber, + Name = rawName, + EffectiveGroupPath = rawGroup, Cells = cells, Headers = Array.Empty(), Error = error, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs index b5a48a91a..3c8766d99 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs @@ -47,6 +47,30 @@ public sealed class HeidenhainAuthoringUxTests result.CanCommit.ShouldBeFalse(); } + [Fact] + public void Csv_preview_invalid_rows_echo_the_raw_name_and_group_847() + { + // lmxopcua#847: an invalid row rendered a BLANK Name in the review grid, so the operator could not tell + // which tag failed. Every rejection path must carry the raw Name/group cell as typed. + const string header = "Name,DataType,AccessLevel,TagGroupPath,TagConfigJson"; + var exec = Json("""{"address":"Status/ExecutionState"}"""); + var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, + header + "\n" + string.Join("\n", + $"rw,Int32,ReadWrite,Spindle,{exec}", // row rule refusal + $"nodt,,Read,,{exec}", // DataType required + $"badjson,Int32,Read,,{Json("{not json")}", + $"bad/name,Int32,Read,,{exec}", // name segment refusal + $"badacc,Int32,Bogus,,{exec}"), // FormatException path + groupPathPrefix: "Axes"); + + result.Rows.Count.ShouldBe(5); + result.Rows.ShouldAllBe(r => !r.Ok); + result.Rows.Select(r => r.Name).ShouldBe(new[] { "rw", "nodt", "badjson", "bad/name", "badacc" }); + result.Rows[0].EffectiveGroupPath.ShouldBe("Axes/Spindle"); + result.Rows[1].EffectiveGroupPath.ShouldBe("Axes"); + result.Rows.Select(r => r.RowNumber).ShouldBe(new[] { 1, 2, 3, 4, 5 }); + } + [Fact] public async Task Csv_preview_and_ImportTags_agree_row_for_row() {