feat(adminui): add generic CollectionEditor<TRow> modal list editor

This commit is contained in:
Joseph Doherty
2026-05-29 09:03:03 -04:00
parent b76561a780
commit e2fa6754bb
@@ -0,0 +1,129 @@
@* Generic modal-per-row list editor. The parent owns the List<TRow> (a MUTABLE row VM,
because driver contracts are immutable records). This renders a read-only table with
Add/Edit/Delete and a modal that edits a CLONED working copy — commit on Save, discard
on Cancel. NewRow builds a default VM; Clone copies one for the working copy; Validate
(optional) returns an error string to block commit or null to allow. *@
@typeparam TRow
<section class="panel rise mt-3" style="@_styleDelay">
<div class="panel-head d-flex align-items-center">
<span>@Title (@Items.Count)</span>
<button type="button" class="btn btn-sm btn-outline-primary ms-auto" @onclick="Add">+ Add @ItemNoun</button>
</div>
@if (Items.Count == 0)
{
<div style="padding:1rem" class="text-muted">No @ItemNoun.ToLowerInvariant() rows.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>@HeaderTemplate</thead>
<tbody>
@for (var i = 0; i < Items.Count; i++)
{
var idx = i;
<tr>
@RowTemplate(Items[idx])
<td class="text-end" style="white-space:nowrap">
<button type="button" class="btn btn-sm btn-link p-0 me-2" @onclick="() => Edit(idx)">Edit</button>
<button type="button" class="btn btn-sm btn-link p-0 text-danger" @onclick="() => Delete(idx)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
@if (_modalOpen && _working is not null)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(_editIndex is null ? $"Add {ItemNoun}" : $"Edit {ItemNoun}")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
@EditTemplate(_working)
@if (!string.IsNullOrEmpty(_validationError))
{
<div class="text-danger small mt-2">@_validationError</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Commit">Save</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter, EditorRequired] public List<TRow> Items { get; set; } = default!;
[Parameter] public EventCallback ItemsChanged { get; set; }
[Parameter] public string Title { get; set; } = "Items";
[Parameter] public string ItemNoun { get; set; } = "row";
[Parameter] public string AnimationDelay { get; set; } = ".18s";
[Parameter, EditorRequired] public RenderFragment HeaderTemplate { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment<TRow> RowTemplate { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment<TRow> EditTemplate { get; set; } = default!;
[Parameter, EditorRequired] public Func<TRow> NewRow { get; set; } = default!;
[Parameter, EditorRequired] public Func<TRow, TRow> Clone { get; set; } = default!;
[Parameter] public Func<TRow, IReadOnlyList<TRow>, int?, string?>? Validate { get; set; }
private string _styleDelay => $"animation-delay:{AnimationDelay}";
private bool _modalOpen;
private int? _editIndex;
private TRow? _working;
private string? _validationError;
private void Add()
{
_editIndex = null;
_working = NewRow();
_validationError = null;
_modalOpen = true;
}
private void Edit(int index)
{
_editIndex = index;
_working = Clone(Items[index]);
_validationError = null;
_modalOpen = true;
}
private async Task Delete(int index)
{
Items.RemoveAt(index);
await ItemsChanged.InvokeAsync();
}
private void Cancel()
{
_modalOpen = false;
_working = default;
_editIndex = null;
_validationError = null;
}
private async Task Commit()
{
if (_working is null) return;
_validationError = Validate?.Invoke(_working, Items, _editIndex);
if (_validationError is not null) return;
if (_editIndex is int i) Items[i] = _working;
else Items.Add(_working);
_modalOpen = false;
_working = default;
_editIndex = null;
await ItemsChanged.InvokeAsync();
}
}