mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-22 13:57:50 +10:00
Effective config: multi descriptor refactor, validation UX, restore cleanup
- All multi fields use EffectiveMultiValueConfigFieldDescriptor; registry has RegisterMultiDescriptor/RegisterMultiComponent/RegisterMulti; every multi option registered; plain multi fallback removed (AddMultiDescriptor throws). - Descriptor-driven validation: base descriptor ValidateValue + Validate(); integer descriptor enforces Min/Max; ListenAddressMultiValidator for listen-address; single/flag commit uses Descriptor.Validate (no session field issues for inline edits). - Multi list editor: local _displayItems so remove/add/edit update UI immediately; remove button PreventBlurOnMouseDown; inline validation message under input (ec-multi-list-input-group); root div not span. - Save flow: on successful backup restore call OnCloseAndEndEdit then OnSaveCompleted so session and pending changes are cleared and config is reloaded. - Server uses descriptor-only (no custom multi component); ServerMultiValueDisplay removed. RegisterValidatedSingle added for future single-field validation.
This commit is contained in:
+13
-1
@@ -3,6 +3,7 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
||||
@inject IEffectiveConfigEditSession Session
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
@@ -17,6 +18,7 @@
|
||||
}
|
||||
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
|
||||
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
|
||||
ValidationErrorCount="@_validationErrorCount" ValidationWarningCount="@_validationWarningCount"
|
||||
AllSectionsExpanded="@_allSectionsExpanded"
|
||||
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
|
||||
<CascadingValue Value="@Session">
|
||||
@@ -40,6 +42,9 @@
|
||||
private HashSet<string> _openPanels = new();
|
||||
private bool _showSaveModal;
|
||||
|
||||
private int _validationErrorCount => Session.GetValidationSummary().Count(i => i.Severity == FieldIssueSeverity.Error);
|
||||
private int _validationWarningCount => Session.GetValidationSummary().Count(i => i.Severity == FieldIssueSeverity.Warning);
|
||||
|
||||
private string? _optionHelpKey;
|
||||
private string? _optionHelpAnchorId;
|
||||
private string _optionHelpTitle = "Option help";
|
||||
@@ -69,7 +74,7 @@
|
||||
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
|
||||
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
|
||||
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; },
|
||||
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); return InvokeAsync(StateHasChanged); }
|
||||
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,9 +189,16 @@
|
||||
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
|
||||
{
|
||||
Session.TrackCommit(args);
|
||||
RunCrossOptionEvaluator();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void RunCrossOptionEvaluator()
|
||||
{
|
||||
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges);
|
||||
Session.SetCrossOptionIssues(issues);
|
||||
}
|
||||
|
||||
private void OpenSaveModal()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
|
||||
@@ -30,6 +30,20 @@
|
||||
@PendingChangesCount change(s)
|
||||
</span>
|
||||
}
|
||||
@if (ValidationErrorCount > 0 || ValidationWarningCount > 0)
|
||||
{
|
||||
<span class="small ec-toolbar-validation-count" aria-live="polite">
|
||||
@if (ValidationErrorCount > 0)
|
||||
{
|
||||
<span class="text-danger fw-medium">errors: @ValidationErrorCount</span>
|
||||
}
|
||||
@if (ValidationErrorCount > 0 && ValidationWarningCount > 0) { <span class="text-muted">·</span> }
|
||||
@if (ValidationWarningCount > 0)
|
||||
{
|
||||
<span class="text-warning">warnings: @ValidationWarningCount</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -46,6 +60,8 @@
|
||||
[Parameter] public EventCallback<string> SearchTermChanged { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public int PendingChangesCount { get; set; }
|
||||
[Parameter] public int ValidationErrorCount { get; set; }
|
||||
[Parameter] public int ValidationWarningCount { get; set; }
|
||||
[Parameter] public bool AllSectionsExpanded { get; set; }
|
||||
[Parameter] public EventCallback OnExpandAll { get; set; }
|
||||
[Parameter] public EventCallback OnCollapseAll { get; set; }
|
||||
|
||||
+13
-2
@@ -34,9 +34,20 @@ else
|
||||
|
||||
private async Task OnInput(ChangeEventArgs e)
|
||||
{
|
||||
int? value = null;
|
||||
if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max)
|
||||
var raw = e.Value?.ToString();
|
||||
object? value = null;
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
value = null;
|
||||
}
|
||||
else if (int.TryParse(raw, out var num))
|
||||
{
|
||||
value = num;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = raw;
|
||||
}
|
||||
await NotifyValueChanged(value);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-24
@@ -5,8 +5,6 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@using System.Net
|
||||
@using System.Net.Sockets
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@@ -44,11 +42,32 @@
|
||||
}
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
|
||||
if (customMulti is not null)
|
||||
{
|
||||
@* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@
|
||||
<li class="ec-field @(IsEditMode && IsActive ? "ec-field-editing" : "")">
|
||||
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
<span class="ec-field-value">
|
||||
<CascadingValue Value="@(IsEditMode && IsActive)">
|
||||
<CascadingValue Value="@IsEditMode">
|
||||
@customMulti(Descriptor)
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</span>
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive)"
|
||||
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
|
||||
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -191,7 +210,7 @@ else
|
||||
await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName);
|
||||
return true;
|
||||
}
|
||||
var validationError = ValidateDraftValue(newValue);
|
||||
var validationError = Descriptor.Validate(newValue);
|
||||
if (validationError != null)
|
||||
{
|
||||
NotificationService.ShowError(validationError);
|
||||
@@ -261,22 +280,6 @@ else
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private string? ValidateDraftValue(object? value)
|
||||
{
|
||||
if (string.Equals(Descriptor.OptionName, DnsmasqConfKeys.ListenAddress, StringComparison.Ordinal))
|
||||
{
|
||||
var values = AsStringList(value) ?? (value is string s ? new[] { s } : null);
|
||||
if (values == null) return null;
|
||||
foreach (var raw in values)
|
||||
{
|
||||
var v = raw.Trim();
|
||||
if (!IPAddress.TryParse(v, out var ip) || (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6))
|
||||
return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 or IPv6 address.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(object? value)
|
||||
{
|
||||
return value as IReadOnlyList<string>;
|
||||
|
||||
+132
-43
@@ -2,33 +2,44 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<span class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
<ol class="ec-field-value-list">
|
||||
@if (SourceItems?.Count > 0)
|
||||
@if (_displayItems.Count > 0)
|
||||
{
|
||||
@for (var i = 0; i < SourceItems.Count; i++)
|
||||
@for (var i = 0; i < _displayItems.Count; i++)
|
||||
{
|
||||
var item = SourceItems[i];
|
||||
var item = _displayItems[i];
|
||||
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
|
||||
var itemReadonly = item.Source?.IsReadOnly == true;
|
||||
var rowIndex = i;
|
||||
var rowValue = item.Value;
|
||||
var rowInlineError = GetInlineErrorForIndex(rowIndex);
|
||||
|
||||
<li class="ec-multi-list-row" @key="anchorId">
|
||||
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||
Title="Apply change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard edit" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input class="form-control form-control-sm ec-multi-list-input @(rowInlineError != null ? "is-invalid" : "")"
|
||||
placeholder="@(Behavior?.Placeholder ?? "")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
@if (rowInlineError != null)
|
||||
{
|
||||
<div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@rowInlineError</div>
|
||||
}
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||
Title="Apply change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard edit" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -48,6 +59,7 @@
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
|
||||
Title="Remove this value" AriaLabel="Remove"
|
||||
PreventBlurOnMouseDown="true"
|
||||
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
|
||||
}
|
||||
</span>
|
||||
@@ -64,20 +76,28 @@
|
||||
|
||||
@if (AllowAddManagedValue)
|
||||
{
|
||||
<li class="ec-multi-list-row ec-multi-list-add-item" @key="AddItemKey">
|
||||
<li class="ec-multi-list-row ec-multi-list-add-item @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
|
||||
@if (_addingNew)
|
||||
{
|
||||
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="New value"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
|
||||
Title="Add this value" AriaLabel="Add"
|
||||
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard" AriaLabel="Cancel"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input @(AddInlineError != null ? "is-invalid" : "")"
|
||||
placeholder="@(Behavior?.Placeholder ?? "New value")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
@if (AddInlineError != null)
|
||||
{
|
||||
<div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@AddInlineError</div>
|
||||
}
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
|
||||
Title="Add this value" AriaLabel="Add"
|
||||
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard" AriaLabel="Cancel"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -88,7 +108,7 @@
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
|
||||
@@ -98,6 +118,10 @@
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
|
||||
|
||||
[Parameter] public IMultiValueEditBehavior? Behavior { get; set; }
|
||||
[Parameter] public IMultiValueOptionValidator? Validator { get; set; }
|
||||
[Parameter] public string? FieldKey { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
|
||||
|
||||
private const string AddItemKey = "add-item";
|
||||
@@ -107,9 +131,22 @@
|
||||
private bool _didAutoOpenAdd;
|
||||
private bool _shouldFocusAddInput;
|
||||
private ElementReference _addNewInputRef;
|
||||
private int? _inlineErrorIndex;
|
||||
private string? _inlineErrorMessage;
|
||||
|
||||
private List<ValueWithSource> _displayItems = new();
|
||||
private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList();
|
||||
|
||||
private string? AddInlineError => _addingNew && _inlineErrorIndex is null ? _inlineErrorMessage : null;
|
||||
private string? GetInlineErrorForIndex(int? editIndex)
|
||||
{
|
||||
return _editingIndex == editIndex && _inlineErrorIndex == editIndex ? _inlineErrorMessage : null;
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
|
||||
|
||||
if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue)
|
||||
{
|
||||
_didAutoOpenAdd = true;
|
||||
@@ -139,6 +176,7 @@
|
||||
_addingNew = false;
|
||||
_editingIndex = index;
|
||||
_draftValue = current;
|
||||
ClearInlineError();
|
||||
}
|
||||
|
||||
private void BeginAdd()
|
||||
@@ -147,6 +185,7 @@
|
||||
_addingNew = true;
|
||||
_draftValue = "";
|
||||
_shouldFocusAddInput = true;
|
||||
ClearInlineError();
|
||||
}
|
||||
|
||||
private void CancelInline()
|
||||
@@ -154,46 +193,96 @@
|
||||
_editingIndex = null;
|
||||
_addingNew = false;
|
||||
_draftValue = "";
|
||||
ClearInlineError();
|
||||
}
|
||||
|
||||
private async Task SaveEdit(int index)
|
||||
{
|
||||
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
var current = _currentValues;
|
||||
var next = current.ToList();
|
||||
if (index < 0 || index >= next.Count) return;
|
||||
|
||||
next[index] = (_draftValue ?? "").Trim();
|
||||
var raw = _draftValue ?? "";
|
||||
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
SetInlineError(index, "Value cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
var validationError = Validator?.ValidateItem(normalized, current, index);
|
||||
if (validationError != null)
|
||||
{
|
||||
SetInlineError(index, validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal)))
|
||||
{
|
||||
SetInlineError(index, "Duplicate values are not allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
ClearInlineError();
|
||||
next[index] = normalized;
|
||||
_displayItems[index] = _displayItems[index] with { Value = normalized };
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
CancelInline();
|
||||
}
|
||||
|
||||
private async Task SaveNew()
|
||||
{
|
||||
var value = (_draftValue ?? "").Trim();
|
||||
if (value.Length == 0) return;
|
||||
var raw = _draftValue ?? "";
|
||||
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
|
||||
if (normalized.Length == 0) return;
|
||||
|
||||
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
var current = _currentValues;
|
||||
var validationError = Validator?.ValidateItem(normalized, current, null);
|
||||
if (validationError != null)
|
||||
{
|
||||
SetInlineError(null, validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
next.Add(value);
|
||||
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
|
||||
{
|
||||
SetInlineError(null, "Duplicate values are not allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
ClearInlineError();
|
||||
var next = current.ToList();
|
||||
next.Add(normalized);
|
||||
_displayItems.Add(new ValueWithSource(normalized, null));
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
CancelInline();
|
||||
}
|
||||
|
||||
private void SetInlineError(int? itemIndex, string message)
|
||||
{
|
||||
_inlineErrorIndex = itemIndex;
|
||||
_inlineErrorMessage = message;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ClearInlineError()
|
||||
{
|
||||
_inlineErrorIndex = null;
|
||||
_inlineErrorMessage = null;
|
||||
}
|
||||
|
||||
private async Task RemoveAt(int index)
|
||||
{
|
||||
if (SourceItems == null || index < 0 || index >= SourceItems.Count) return;
|
||||
var item = SourceItems[index];
|
||||
if (index < 0 || index >= _displayItems.Count) return;
|
||||
var item = _displayItems[index];
|
||||
if (item?.Source?.IsReadOnly == true) return;
|
||||
|
||||
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
var next = _displayItems.Select(x => x.Value).ToList();
|
||||
if (index < 0 || index >= next.Count) return;
|
||||
|
||||
next.RemoveAt(index);
|
||||
_displayItems.RemoveAt(index);
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
+33
-3
@@ -20,6 +20,33 @@
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-row.ec-multi-list-row-with-controls {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ec-multi-list-row-controls {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-multi-list-input-group {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-invalid-feedback {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .ec-field-value-item {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -32,8 +59,9 @@
|
||||
}
|
||||
|
||||
.ec-multi-list-row .form-control.ec-multi-list-input {
|
||||
max-width: 28rem;
|
||||
max-width: none;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-multi-list-empty-hint {
|
||||
@@ -51,11 +79,13 @@
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .form-control.ec-multi-list-input {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .ec-multi-list-input-group {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .status-badge-inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
+8
-2
@@ -12,6 +12,9 @@
|
||||
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
|
||||
IsEditMode="@IsEditMode"
|
||||
AllowAddManagedValue="@IsEditMode"
|
||||
FieldKey="@FieldKey"
|
||||
Behavior="@(_multiDesc?.Behavior)"
|
||||
Validator="@(_multiDesc?.Validator)"
|
||||
OnValuesChanged="@ItemsChanged" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
@@ -20,7 +23,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi">
|
||||
<div class="ec-field-value ec-field-value-multi">
|
||||
@if (Items?.Count > 0)
|
||||
{
|
||||
<ol class="ec-field-value-list">
|
||||
@@ -37,12 +40,13 @@
|
||||
<OptionStateBadge OptionName="@Label" ShowEditableBadge="true" EditableAsButton="true"
|
||||
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" />
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; }
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
|
||||
[Parameter] public string? LabelTooltip { get; set; }
|
||||
@@ -58,4 +62,6 @@
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
|
||||
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
if (Session.HasBlockingValidationErrors())
|
||||
{
|
||||
NotificationService.ShowError("Fix validation errors before saving.");
|
||||
return;
|
||||
}
|
||||
|
||||
_saveState = SaveUiState.Saving;
|
||||
_saveResult = null;
|
||||
@@ -114,7 +119,7 @@
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnCloseAndEndEdit.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user