feat(centralui): InstanceConfigure CSV bulk override import (T16)

This commit is contained in:
Joseph Doherty
2026-06-18 02:30:33 -04:00
parent 25c9240415
commit d5e7e897c0
3 changed files with 497 additions and 1 deletions
@@ -175,10 +175,49 @@
@* Attribute Overrides *@
<div class="card mb-3">
<div class="card-header py-2">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<strong>Attribute Overrides</strong>
@* M7-T16: bulk import of attribute overrides from a CSV
(AttributeName,Value[,ElementType]). Selecting a file parses +
validates it against this instance's overridable attributes and —
all-or-nothing — applies every row through the SAME
SetAttributeOverrideAsync path the manual editor uses, or shows the
per-line error list and applies nothing. *@
@if (_overrideAttrs.Count > 0)
{
<label class="btn btn-outline-secondary btn-sm mb-0">
Import overrides (CSV)
<InputFile OnChange="OnCsvImportSelectedAsync"
accept=".csv"
data-test="csv-import-input"
class="d-none"
disabled="@_saving" />
</label>
}
</div>
<div class="card-body p-0">
@if (_csvImportResult is not null)
{
<div class="p-2 pb-0">
<div class="alert @(_csvImportSucceeded ? "alert-success" : "alert-danger") small mb-0"
data-test="csv-import-result">
@if (_csvImportSucceeded)
{
<span>@_csvImportResult</span>
}
else
{
<div class="fw-semibold mb-1">@_csvImportResult</div>
<ul class="mb-0 ps-3">
@foreach (var err in _csvImportErrors)
{
<li>@err</li>
}
</ul>
}
</div>
</div>
}
@if (_overrideAttrs.Count == 0)
{
<p class="text-muted small p-3 mb-0">No overridable (non-locked) attributes in this template.</p>
@@ -557,6 +596,17 @@
// un-parseable List element caught on the pre-submit round-trip).
private Dictionary<string, string> _overrideErrors = new();
// M7-T16: CSV bulk-import result summary. _csvImportResult is the headline
// ("Imported N overrides." or "Import rejected — N error(s)."); on failure the
// per-line messages are listed from _csvImportErrors. Null until an import runs.
private string? _csvImportResult;
private bool _csvImportSucceeded;
private IReadOnlyList<string> _csvImportErrors = Array.Empty<string>();
// Reject pathologically large uploads before buffering — a few hundred KB of
// override CSV is already extreme (thousands of attributes).
private const long MaxCsvImportBytes = 512 * 1024;
// Alarm overrides — read-only state pulled from the repo. The edit modal
// is the only mutation path (one alarm at a time).
private List<TemplateAlarm> _overridableAlarms = new();
@@ -995,6 +1045,122 @@
_saving = false;
}
// ── M7-T16: CSV bulk override import ────────────────────
/// <summary>
/// Handles a selected override CSV. Reads the file text (size-capped), parses it
/// with the shared <see cref="OverrideCsvParser"/>, validates every row against
/// the instance's overridable attributes via <see cref="BuildCsvOverrideImport"/>,
/// and — all-or-nothing — either applies the parsed overrides through the SAME
/// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor uses, or
/// shows the per-line error list and applies nothing. The override editor is
/// refreshed in-place so the applied values are immediately visible.
/// </summary>
private async Task OnCsvImportSelectedAsync(InputFileChangeEventArgs e)
{
_saving = true;
_csvImportResult = null;
_csvImportSucceeded = false;
_csvImportErrors = Array.Empty<string>();
try
{
var file = e.File;
if (file.Size > MaxCsvImportBytes)
{
ShowCsvImportFailure(
$"File too large ({file.Size:N0} bytes). The maximum is {MaxCsvImportBytes:N0} bytes.",
Array.Empty<string>());
return;
}
string text;
using (var reader = new StreamReader(file.OpenReadStream(MaxCsvImportBytes)))
{
text = await reader.ReadToEndAsync();
}
var parsed = OverrideCsvParser.Parse(text);
var outcome = BuildCsvOverrideImport(parsed, _overrideAttrs);
if (outcome.HasErrors)
{
ShowCsvImportFailure(
$"Import rejected — {outcome.Errors.Count} error(s); no overrides applied.",
outcome.Errors);
return;
}
if (outcome.Overrides.Count == 0)
{
ShowCsvImportFailure("No override rows found in the file.", Array.Empty<string>());
return;
}
// Apply through the EXISTING per-attribute submit path (no new server
// method) — identical to SaveOverrides. Update the editor's in-memory
// state so the applied values render immediately.
var user = await GetCurrentUserAsync();
var failures = new List<string>();
foreach (var (attrName, value) in outcome.Overrides)
{
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
if (result.IsSuccess)
{
_existingOverrides[attrName] = result.Value!;
if (value is null) _overrideValues.Remove(attrName);
else _overrideValues[attrName] = value;
RefreshEditorRowForImport(attrName, value);
}
else
{
failures.Add($"{attrName}: {result.Error}");
}
}
if (failures.Count > 0)
{
ShowCsvImportFailure(
$"Applied {outcome.Overrides.Count - failures.Count} of {outcome.Overrides.Count}; "
+ $"{failures.Count} failed.",
failures);
}
else
{
_csvImportSucceeded = true;
_csvImportResult = $"Imported {outcome.Overrides.Count} override(s).";
_toast.ShowSuccess(_csvImportResult);
}
}
catch (Exception ex)
{
ShowCsvImportFailure($"Import failed: {ex.Message}", Array.Empty<string>());
}
finally
{
_saving = false;
}
}
private void ShowCsvImportFailure(string headline, IReadOnlyList<string> errors)
{
_csvImportSucceeded = false;
_csvImportResult = headline;
_csvImportErrors = errors;
_toast.ShowError(headline);
}
/// <summary>
/// Re-seeds the List editor's working rows for an imported List attribute so the
/// applied value renders immediately. Scalar inputs read <c>_overrideValues</c>
/// directly, so nothing extra is needed for them.
/// </summary>
private void RefreshEditorRowForImport(string attrName, string? value)
{
var attr = _overrideAttrs.FirstOrDefault(a => a.Name == attrName);
if (attr?.DataType == DataType.List)
_listRows[attrName] = DecodeListRows(value, attr.ElementDataType);
}
// ── Alarm overrides ─────────────────────────────────────
private bool HasOverride(string alarmName) =>