Effective config: no-0x20 precedence, write semantics, validators, strip-mac/strip-subnet

- ConfigSetCache: no-0x20-encode wins per dnsmasq docs; effective state and source attribution updated
- ReadonlyConfigCommandGenerator: use EffectiveConfigWriteSemantics for inverse-pair and key-only-or-value
- SpecialOptionValidators for UseStaleCache, AddMac, AddSubnet, Umbrella; wired via RegisterValidatedSingle
- OptionSemantics + EffectiveConfigWriteSemantics (GetBehavior/GetInversePairKeys); remove EffectiveConfigWriteBehaviorMap
- strip-mac / strip-subnet: full support in EffectiveDnsmasqConfig, sources, cache, sections, field builder, registry, tests
- Multi-value editor: toasts and block commit on validation error; 2s toast dedupe
- Do0x20 view label comment + Do0x20EncodeDisplayLabelTests
- DnsmasqOptionTooltips + extract-option-help.sh for strip-mac/strip-subnet; regenerated option-help HTML
This commit is contained in:
2026-03-09 00:17:29 +10:00
parent d910688742
commit 9e42a4e2df
24 changed files with 788 additions and 106 deletions
@@ -11,6 +11,7 @@
}
else
{
@* Intentionally show both Default and Disabled as "Disabled" (product choice: collapse unset and off). Do not "fix" to show "Default" for unset. *@
@(GetState() == ExplicitToggleState.Enabled ? "Enabled" : "Disabled")
}
@@ -3,7 +3,9 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using Microsoft.AspNetCore.Components.Web
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IJSRuntime JSRuntime
@inject INotificationService NotificationService
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
<ol class="ec-field-value-list">
@@ -16,20 +18,15 @@
var itemReadonly = item.Source?.IsReadOnly == true;
var rowIndex = i;
var rowValue = item.Value;
var rowInlineError = GetInlineErrorForIndex(rowIndex);
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<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" : "")"
<input class="form-control form-control-sm ec-multi-list-input"
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"
@@ -81,13 +78,9 @@
{
<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" : "")"
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
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"
@@ -125,24 +118,20 @@
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
private const string AddItemKey = "add-item";
private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
private int? _editingIndex;
private bool _addingNew;
private string _draftValue = "";
private bool _didAutoOpenAdd;
private bool _shouldFocusAddInput;
private ElementReference _addNewInputRef;
private int? _inlineErrorIndex;
private string? _inlineErrorMessage;
private string? _lastValidationErrorMessage;
private DateTime _lastValidationErrorUtc;
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();
@@ -176,7 +165,6 @@
_addingNew = false;
_editingIndex = index;
_draftValue = current;
ClearInlineError();
}
private void BeginAdd()
@@ -185,7 +173,6 @@
_addingNew = true;
_draftValue = "";
_shouldFocusAddInput = true;
ClearInlineError();
}
private void CancelInline()
@@ -193,7 +180,18 @@
_editingIndex = null;
_addingNew = false;
_draftValue = "";
ClearInlineError();
_lastValidationErrorMessage = null;
}
private void ShowValidationError(string message)
{
var now = DateTime.UtcNow;
if (string.Equals(_lastValidationErrorMessage, message, StringComparison.Ordinal) &&
(now - _lastValidationErrorUtc) < ValidationToastDedupeWindow)
return;
_lastValidationErrorMessage = message;
_lastValidationErrorUtc = now;
NotificationService.ShowError(message);
}
private async Task SaveEdit(int index)
@@ -206,24 +204,23 @@
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0)
{
SetInlineError(index, "Value cannot be empty.");
ShowValidationError("Value cannot be empty.");
return;
}
var validationError = Validator?.ValidateItem(normalized, current, index);
if (validationError != null)
{
SetInlineError(index, validationError);
ShowValidationError(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.");
ShowValidationError("Duplicate values are not allowed.");
return;
}
ClearInlineError();
next[index] = normalized;
_displayItems[index] = _displayItems[index] with { Value = normalized };
await OnValuesChanged.InvokeAsync(next);
@@ -234,23 +231,26 @@
{
var raw = _draftValue ?? "";
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0) return;
if (normalized.Length == 0)
{
ShowValidationError("Value cannot be empty.");
return;
}
var current = _currentValues;
var validationError = Validator?.ValidateItem(normalized, current, null);
if (validationError != null)
{
SetInlineError(null, validationError);
ShowValidationError(validationError);
return;
}
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
{
SetInlineError(null, "Duplicate values are not allowed.");
ShowValidationError("Duplicate values are not allowed.");
return;
}
ClearInlineError();
var next = current.ToList();
next.Add(normalized);
_displayItems.Add(new ValueWithSource(normalized, null));
@@ -258,19 +258,6 @@
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 (index < 0 || index >= _displayItems.Count) return;
@@ -284,7 +284,7 @@
private static string FormatValue(object? v, string? optionName = null)
{
if (optionName != null && EffectiveConfigWriteBehaviorMap.GetBehavior(optionName) == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
if (optionName != null && EffectiveConfigWriteSemantics.GetBehavior(optionName) == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
return FormatKeyOnlyOrValue(v);
if (v == null) return "(not set)";
if (v is bool b) return b ? "on" : "off";