feat(uns): TagConfig JSON helper + editor map + TagModal dispatch scaffold (F-uns-1 T2)
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
@@ -81,11 +82,25 @@
|
||||
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="tag-config">Tag config (JSON)</label>
|
||||
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "register": 40001, "scale": 0.1 }' />
|
||||
<div class="form-text">Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.</div>
|
||||
<label class="form-label">Tag config</label>
|
||||
@{
|
||||
var editorType = TagConfigEditorMap.Resolve(SelectedDriverType);
|
||||
}
|
||||
@if (string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="form-text">Pick a driver above to configure this tag.</div>
|
||||
}
|
||||
else if (editorType is not null)
|
||||
{
|
||||
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "register": 40001, "scale": 0.1 }' />
|
||||
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
||||
}
|
||||
<ValidationMessage For="@(() => _form.TagConfig)" />
|
||||
</div>
|
||||
|
||||
@@ -137,6 +152,16 @@
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
|
||||
private string? SelectedDriverType =>
|
||||
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
|
||||
|
||||
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
|
||||
{
|
||||
["ConfigJson"] = _form.TagConfig,
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||
};
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component (mirrors
|
||||
/// <c>DriverEditRouter._componentMap</c>). Drivers absent from the map fall back to the generic
|
||||
/// raw-JSON textarea in the TagModal.
|
||||
/// </summary>
|
||||
public static class TagConfigEditorMap
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, Type> Map =
|
||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Editors registered by later tasks, e.g.:
|
||||
// ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
public static Type? Resolve(string? driverType)
|
||||
=> driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for per-driver tag-config editors: parse a TagConfig JSON string into a mutable
|
||||
/// <see cref="JsonObject"/> (preserving every key, so fields the editor doesn't expose survive a
|
||||
/// load→save), read typed scalars, and serialise back.
|
||||
/// </summary>
|
||||
public static class TagConfigJson
|
||||
{
|
||||
/// <summary>Parses <paramref name="json"/> into a mutable object; returns a fresh empty object on null/blank/malformed/non-object input.</summary>
|
||||
public static JsonObject ParseOrNew(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) { return new JsonObject(); }
|
||||
try { return JsonNode.Parse(json) as JsonObject ?? new JsonObject(); }
|
||||
catch (JsonException) { return new JsonObject(); }
|
||||
}
|
||||
|
||||
/// <summary>Serialises the object to compact JSON (JsonNode.ToJsonString() defaults to non-indented).</summary>
|
||||
public static string Serialize(JsonObject obj) => obj.ToJsonString();
|
||||
|
||||
/// <summary>Reads a string value, or null if absent/null/non-string.</summary>
|
||||
public static string? GetString(JsonObject o, string name)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
|
||||
/// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric (incl. object/array nodes).</summary>
|
||||
public static int GetInt(JsonObject o, string name, int fallback = 0)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : fallback;
|
||||
|
||||
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
|
||||
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
|
||||
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
|
||||
|
||||
/// <summary>Sets a string/number/enum-name value (enums via ToString()). Null removes the key.</summary>
|
||||
public static void Set(JsonObject o, string name, object? value)
|
||||
=> o[name] = value is null ? null : JsonValue.Create(value is Enum e ? e.ToString() : value);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class TagConfigJsonTests
|
||||
{
|
||||
private enum Flavor { None, Vanilla, Chocolate }
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{not json")]
|
||||
[InlineData("[1,2]")]
|
||||
public void ParseOrNew_returns_empty_object_for_unusable_input(string? json)
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew(json);
|
||||
|
||||
obj.ShouldNotBeNull();
|
||||
obj.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseOrNew_parses_an_object()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"foo":"bar"}""");
|
||||
|
||||
obj.Count.ShouldBe(1);
|
||||
TagConfigJson.GetString(obj, "foo").ShouldBe("bar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_preserves_unknown_keys()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"foo":"bar","region":"X"}""");
|
||||
|
||||
TagConfigJson.Set(obj, "region", "Y");
|
||||
var json = TagConfigJson.Serialize(obj);
|
||||
|
||||
json.ShouldContain("\"foo\":\"bar\"");
|
||||
json.ShouldContain("\"region\":\"Y\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetString_returns_null_when_absent()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.GetString(obj, "missing").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInt_reads_an_int()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"register":40001}""");
|
||||
|
||||
TagConfigJson.GetInt(obj, "register").ShouldBe(40001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInt_falls_back_when_absent()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.GetInt(obj, "register", fallback: 7).ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInt_falls_back_when_value_is_a_non_scalar_node()
|
||||
{
|
||||
// A nested object/array under the key must not throw — GetInt returns the fallback.
|
||||
var obj = TagConfigJson.ParseOrNew("""{"register":{},"other":[1,2]}""");
|
||||
|
||||
TagConfigJson.GetInt(obj, "register", fallback: 7).ShouldBe(7);
|
||||
TagConfigJson.GetInt(obj, "other", fallback: 9).ShouldBe(9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetString_returns_null_when_value_is_a_non_string_node()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"register":40001,"nested":{}}""");
|
||||
|
||||
TagConfigJson.GetString(obj, "register").ShouldBeNull();
|
||||
TagConfigJson.GetString(obj, "nested").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnum_parses_by_name_case_insensitive()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"flavor":"chocolate"}""");
|
||||
|
||||
TagConfigJson.GetEnum(obj, "flavor", Flavor.None).ShouldBe(Flavor.Chocolate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnum_falls_back_when_absent()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.GetEnum(obj, "flavor", Flavor.Vanilla).ShouldBe(Flavor.Vanilla);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnum_falls_back_on_garbage()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"flavor":"strawberry"}""");
|
||||
|
||||
TagConfigJson.GetEnum(obj, "flavor", Flavor.Vanilla).ShouldBe(Flavor.Vanilla);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_writes_an_enum_as_its_name_string()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.Set(obj, "flavor", Flavor.Chocolate);
|
||||
var json = TagConfigJson.Serialize(obj);
|
||||
|
||||
json.ShouldContain($"\"flavor\":\"{Flavor.Chocolate}\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_with_null_removes_the_key()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"region":"X"}""");
|
||||
|
||||
TagConfigJson.Set(obj, "region", null);
|
||||
|
||||
TagConfigJson.GetString(obj, "region").ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user