feat(adminui): inline script-source editor in the virtual-tag modal

This commit is contained in:
Joseph Doherty
2026-06-09 15:17:25 -04:00
parent 088fc50ef2
commit fc7dc3b57d
4 changed files with 478 additions and 4 deletions
@@ -47,7 +47,8 @@
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-script">Script</label>
<InputSelect id="vtag-script" @bind-Value="_form.ScriptId" class="form-select form-select-sm">
<InputSelect id="vtag-script" @bind-Value="_form.ScriptId"
@bind-Value:after="OnScriptChangedAsync" class="form-select form-select-sm">
<option value="">— pick script —</option>
@foreach (var (id, display) in Scripts)
{
@@ -57,6 +58,48 @@
<ValidationMessage For="@(() => _form.ScriptId)" />
</div>
</div>
@* Inline script-source editor. Shown only when a script is bound. This panel saves
the SHARED Script row on its OWN concurrency-guarded button — it is deliberately
separate from the virtual-tag Create/Save below and never touches _form or closes
the modal. *@
@if (!string.IsNullOrEmpty(_form.ScriptId) && _scriptLoaded)
{
<div class="card mb-3">
<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 all of them.
</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>
}
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Change-triggered</label>
@@ -92,8 +135,8 @@
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@(_busy || _scriptSaving)">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@(_busy || _scriptSaving)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
@@ -135,8 +178,37 @@
private bool _busy;
private string? _error;
protected override void OnParametersSet()
// Tracks which open this modal last loaded for, so unrelated Blazor Server re-renders don't
// rebuild _form / reload the script source and clobber in-progress edits. Null while closed.
private string? _loadedKey;
// Inline script-source editor state. Independent of _form and the virtual-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;
protected override async Task OnParametersSetAsync()
{
if (!Visible)
{
_loadedKey = null; // closed → next open reloads fresh
return;
}
// Guard against unrelated re-renders. In Blazor Server any live-status push re-invokes
// OnParametersSetAsync; without this the rebuild + script reload below would silently
// discard whatever the operator has typed. Only rebuild when the modal OPENS or the
// target entity CHANGES.
var key = IsNew ? "<new>" : Existing?.VirtualTagId;
if (key == _loadedKey) return; // same open, re-render → preserve in-progress form + script edits
_loadedKey = key;
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
@@ -157,6 +229,105 @@
};
}
_error = null;
_scriptExpanded = false;
// Load the bound script's source for the inline panel when editing an existing tag that
// already has a script (the open-load path; selection changes go through OnScriptChangedAsync).
await LoadScriptSourceAsync();
}
/// <summary>
/// Reloads the inline-editor state from the currently selected script. Clears it when no script is
/// bound. This drives both the open-load and the post-selection refresh, and never touches _form.
/// </summary>
private async Task LoadScriptSourceAsync()
{
_scriptSaved = false;
_scriptError = null;
if (string.IsNullOrEmpty(_form.ScriptId))
{
_scriptSource = "";
_scriptLoaded = false;
_scriptRowVersion = Array.Empty<byte>();
_scriptName = null;
_scriptUsageCount = 0;
return;
}
var loaded = await Svc.GetScriptSourceAsync(_form.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(_form.ScriptId);
}
/// <summary>Refreshes the inline script panel when the operator picks a different script.</summary>
private Task OnScriptChangedAsync() => LoadScriptSourceAsync();
/// <summary>
/// Toggles the inline script-source panel. Clears the stale "Script saved." banner when
/// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save.
/// </summary>
private void ToggleScriptPanel()
{
_scriptExpanded = !_scriptExpanded;
if (_scriptExpanded)
{
_scriptSaved = false;
}
}
/// <summary>
/// Saves the edited script body via its own RowVersion-guarded service call. This is wholly
/// separate from the virtual-tag save: it does not touch _form, does not raise OnSaved, and does
/// not close the modal. On success it re-fetches the source so the panel holds a fresh token.
/// </summary>
private async Task SaveScriptAsync()
{
if (string.IsNullOrEmpty(_form.ScriptId) || !_scriptLoaded)
{
return;
}
_scriptSaving = true;
_scriptSaved = false;
_scriptError = null;
try
{
var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion);
if (result.Ok)
{
// Re-fetch to refresh the concurrency token for any subsequent save.
var reloaded = await Svc.GetScriptSourceAsync(_form.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;
}
}
private async Task SaveAsync()