feat(adminui): B2-WP4 manual tag entry + raw tag modal + Calculation editor
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
+279
@@ -0,0 +1,279 @@
|
||||
@* Typed TagConfig editor for the Calculation pseudo-driver. Authors the calc binding —
|
||||
scriptId + change/timer triggers — reusing the VirtualTagModal's script dropdown, "New
|
||||
script" action, and inline Monaco source panel (all backed by the same IUnsTreeService
|
||||
script seams). Dispatched from RawTagModal / RawManualTagEntryModal via TagConfigEditorMap,
|
||||
so it takes the same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter
|
||||
shape every typed editor takes; DriverType/GetDriverConfigJson are accepted for dispatch
|
||||
uniformity but unused (a calc tag has no address to browse). *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Script</label>
|
||||
<select class="form-select form-select-sm" value="@_m.ScriptId" @onchange="OnScriptChangedAsync">
|
||||
<option value="">— pick script —</option>
|
||||
@foreach (var (id, display) in _scripts)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Change-triggered</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="calc-change-@_uid"
|
||||
checked="@_m.ChangeTriggered"
|
||||
@onchange="@(e => Update(() => _m.ChangeTriggered = e.Value is true))" />
|
||||
<label class="form-check-label small" for="calc-change-@_uid">On dependency change</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timer (ms)</label>
|
||||
<input type="number" min="50" step="1" class="form-control form-control-sm"
|
||||
value="@_m.TimerIntervalMs"
|
||||
@onchange="@(e => Update(() => _m.TimerIntervalMs = ParseTimer(e.Value)))" />
|
||||
<div class="form-text">Blank = change-trigger only. Min 50 ms.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* No script bound yet → offer a one-click "New script" that creates a blank Script row, binds
|
||||
it, and expands the inline editor so the operator can author the body without leaving. *@
|
||||
@if (string.IsNullOrEmpty(_m.ScriptId))
|
||||
{
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
@onclick="CreateNewScriptAsync" disabled="@_scriptCreating">
|
||||
@if (_scriptCreating) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
New script
|
||||
</button>
|
||||
<span class="form-text ms-2">Creates a blank C# script bound to this calc tag.</span>
|
||||
@if (!string.IsNullOrWhiteSpace(_scriptCreateError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scriptCreateError</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Inline script-source editor — shown only when a script is bound. Saves the SHARED Script row on
|
||||
its own concurrency-guarded button; independent of the enclosing tag save. *@
|
||||
@if (!string.IsNullOrEmpty(_m.ScriptId) && _scriptLoaded)
|
||||
{
|
||||
<div class="card mt-2">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">Script source</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none"
|
||||
@onclick="ToggleScriptPanel">
|
||||
@(_scriptExpanded ? "Hide" : "Edit source")
|
||||
</button>
|
||||
</div>
|
||||
@if (_scriptExpanded)
|
||||
{
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning py-2 small mb-2">
|
||||
Editing shared script "<span class="mono">@_scriptName</span>" — used by
|
||||
@_scriptUsageCount virtual tag(s). Changes affect every tag using it.
|
||||
</div>
|
||||
<MonacoEditor @bind-Value="_scriptSource" Height="300px" />
|
||||
@if (!string.IsNullOrWhiteSpace(_scriptError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scriptError</div>
|
||||
}
|
||||
else if (_scriptSaved)
|
||||
{
|
||||
<div class="text-success small mt-2">Script saved.</div>
|
||||
}
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
@onclick="SaveScriptAsync" disabled="@_scriptSaving">
|
||||
@if (_scriptSaving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity; unused here.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
/// <summary>Live accessor for the selected driver's DriverConfig — accepted for dispatch uniformity;
|
||||
/// unused (a calc tag has no address to browse).</summary>
|
||||
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||
|
||||
private CalculationTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
// Unique suffix so multiple editor instances (e.g. manual-entry grid rows) get distinct control ids.
|
||||
private readonly string _uid = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
// Script dropdown options — loaded once, plus any inline-created script appended.
|
||||
private List<(string Id, string Display)> _scripts = new();
|
||||
|
||||
// Inline script-source editor state (independent of the tag save).
|
||||
private string _scriptSource = "";
|
||||
private bool _scriptLoaded;
|
||||
private byte[] _scriptRowVersion = Array.Empty<byte>();
|
||||
private string? _scriptName;
|
||||
private int _scriptUsageCount;
|
||||
private bool _scriptExpanded;
|
||||
private bool _scriptSaving;
|
||||
private bool _scriptSaved;
|
||||
private string? _scriptError;
|
||||
|
||||
// "New script" action state.
|
||||
private bool _scriptCreating;
|
||||
private string? _scriptCreateError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var scripts = await Svc.LoadScriptsAsync();
|
||||
_scripts = scripts.Select(s => (s.ScriptId, s.Display)).ToList();
|
||||
}
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes) can't reset the operator's in-progress edits.
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = CalculationTagConfigModel.FromJson(ConfigJson);
|
||||
await LoadScriptSourceAsync();
|
||||
}
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await WriteBackAsync();
|
||||
}
|
||||
|
||||
// Push the model back to the host as JSON and pin _lastConfigJson so the echo doesn't re-parse.
|
||||
private async Task WriteBackAsync()
|
||||
{
|
||||
var json = _m.ToJson();
|
||||
_lastConfigJson = json;
|
||||
await ConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private static int? ParseTimer(object? v)
|
||||
=> int.TryParse(v?.ToString(), out var i) && i > 0 ? i : null;
|
||||
|
||||
/// <summary>Refreshes the inline script panel + writes the new binding back when the operator picks
|
||||
/// a different script.</summary>
|
||||
private async Task OnScriptChangedAsync(ChangeEventArgs e)
|
||||
{
|
||||
_m.ScriptId = e.Value?.ToString() ?? "";
|
||||
await WriteBackAsync();
|
||||
await LoadScriptSourceAsync();
|
||||
}
|
||||
|
||||
/// <summary>Loads the bound script's source for the inline panel; clears it when no script is bound.</summary>
|
||||
private async Task LoadScriptSourceAsync()
|
||||
{
|
||||
_scriptSaved = false;
|
||||
_scriptError = null;
|
||||
|
||||
if (string.IsNullOrEmpty(_m.ScriptId))
|
||||
{
|
||||
_scriptSource = "";
|
||||
_scriptLoaded = false;
|
||||
_scriptRowVersion = Array.Empty<byte>();
|
||||
_scriptName = null;
|
||||
_scriptUsageCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var loaded = await Svc.GetScriptSourceAsync(_m.ScriptId);
|
||||
if (loaded is null)
|
||||
{
|
||||
_scriptSource = "";
|
||||
_scriptLoaded = false;
|
||||
_scriptRowVersion = Array.Empty<byte>();
|
||||
_scriptName = null;
|
||||
_scriptUsageCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_scriptSource = loaded.Value.SourceCode;
|
||||
_scriptLoaded = true;
|
||||
_scriptRowVersion = loaded.Value.RowVersion;
|
||||
_scriptName = loaded.Value.Name;
|
||||
_scriptUsageCount = await Svc.CountVirtualTagsUsingScriptAsync(_m.ScriptId);
|
||||
}
|
||||
|
||||
/// <summary>Creates a blank script, binds this calc tag to it, and expands the inline editor. Persists
|
||||
/// only the new Script row — the tag binding lives in the model until the host's save.</summary>
|
||||
private async Task CreateNewScriptAsync()
|
||||
{
|
||||
_scriptCreating = true;
|
||||
_scriptCreateError = null;
|
||||
try
|
||||
{
|
||||
const string seedName = "Calc script";
|
||||
var result = await Svc.CreateScriptAsync(seedName);
|
||||
if (!result.Ok || string.IsNullOrEmpty(result.CreatedId))
|
||||
{
|
||||
_scriptCreateError = result.Error ?? "Could not create the script.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the option BEFORE binding so the <select> can resolve the label on first render.
|
||||
if (!_scripts.Any(s => s.Id == result.CreatedId))
|
||||
{
|
||||
_scripts.Add((result.CreatedId, $"{seedName} (CSharp)"));
|
||||
}
|
||||
_m.ScriptId = result.CreatedId;
|
||||
await WriteBackAsync();
|
||||
await LoadScriptSourceAsync();
|
||||
_scriptExpanded = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scriptCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Toggles the inline script-source panel, clearing a stale "saved" banner on (re)expand.</summary>
|
||||
private void ToggleScriptPanel()
|
||||
{
|
||||
_scriptExpanded = !_scriptExpanded;
|
||||
if (_scriptExpanded) { _scriptSaved = false; }
|
||||
}
|
||||
|
||||
/// <summary>Saves the edited script body via its own RowVersion-guarded call. Wholly separate from the
|
||||
/// tag save: never touches the model or closes the enclosing modal.</summary>
|
||||
private async Task SaveScriptAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_m.ScriptId) || !_scriptLoaded) { return; }
|
||||
|
||||
_scriptSaving = true;
|
||||
_scriptSaved = false;
|
||||
_scriptError = null;
|
||||
try
|
||||
{
|
||||
var result = await Svc.UpdateScriptSourceAsync(_m.ScriptId, _scriptSource, _scriptRowVersion);
|
||||
if (result.Ok)
|
||||
{
|
||||
var reloaded = await Svc.GetScriptSourceAsync(_m.ScriptId);
|
||||
if (reloaded is not null)
|
||||
{
|
||||
_scriptSource = reloaded.Value.SourceCode;
|
||||
_scriptRowVersion = reloaded.Value.RowVersion;
|
||||
_scriptName = reloaded.Value.Name;
|
||||
}
|
||||
_scriptSaved = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_scriptError = result.Error;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scriptSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user