feat(centralui): InstanceConfigure CSV bulk override import (T16)
This commit is contained in:
+167
-1
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user