Hosts UX, effective names, JS interop, domain UI

- Hosts: delete confirmation modal, shift range select, keyboard shortcuts,
  effective-name suffix tooltips, conditional modern header actions
- Align EffectiveNames on rows with scoped domain= + pending preview;
  unified hosts API uses full DomainValues from config snapshot
- Harden Blazor JS interop (safe helpers, IAsyncDisposable, disposal order)
- Effective config: domain multi-value display/editor, parsers, tests
- Misc: docs/testdata/docker script updates
This commit is contained in:
2026-04-05 14:46:17 +10:00
parent 9627926a6a
commit 2e713f0e3c
41 changed files with 1848 additions and 273 deletions
@@ -1,4 +1,5 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Extensions.Interop
@inject IJSRuntime JSRuntime
<div class="d-flex align-items-center gap-1 @CssClass">
@@ -15,13 +16,7 @@
private async Task CopyAsync()
{
if (string.IsNullOrEmpty(Text)) return;
try
{
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
}
catch
{
// Clipboard not available or failed
}
// window.copyToClipboard from wwwroot/js/copy-to-clipboard.js; swallow disconnect / clipboard failures.
await JSRuntime.InvokeVoidAsyncSafe("copyToClipboard", args: new object?[] { Text });
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Components;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Components.EffectiveConfig;
/// <summary>
/// Base for custom effective-config multi-value displays; mirrors <see cref="EffectiveConfigValueDisplayBase"/> for singles.
/// </summary>
public abstract class EffectiveConfigMultiValueDisplayBase : ComponentBase
{
[Parameter]
public EffectiveConfigMultiDisplayContext Context { get; set; } = null!;
[Parameter]
public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
protected IReadOnlyList<ValueWithSource> EffectiveItems => Context.EffectiveItems;
protected IReadOnlyList<string> EffectiveValues => Context.EffectiveValues;
protected Task NotifyValuesChanged(IReadOnlyList<string> values) => OnValuesChanged.InvokeAsync(values);
}
@@ -163,15 +163,19 @@
{
if (Session.Draft.PendingChanges.Count > 0)
{
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
var module = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/dialog.js");
if (module == null)
return;
try
{
var confirmed = await module.InvokeAsync<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
if (!confirmed) return;
var confirmed = await module.InvokeAsyncSafe<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
if (confirmed != true)
return;
}
finally
{
await module.DisposeAsync();
await module.DisposeAsyncSafe();
}
}
Session.Draft.DiscardAllDraft();
@@ -0,0 +1,493 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject INotificationService NotificationService
<div class="ec-field-value ec-field-value-multi ec-domain-rule-editor" @onclick:stopPropagation="true">
@* ul: avoid <ol> markers (scoped CSS does not inherit .ec-multi-list-add-item list-style rules from the generic multi editor). *@
<ul class="ec-field-value-list ec-domain-rule-list">
@if (_displayItems.Count > 0)
{
@for (var i = 0; i < _displayItems.Count; i++)
{
var item = _displayItems[i];
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
var itemReadonly = item.Source?.IsReadOnly == true;
var rowIndex = i;
var rowValue = item.Value;
<li class="ec-domain-rule-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-domain-rule-row-editing" : "")" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-domain-rule-form">
<p class="ec-domain-rule-intro">Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with <code>expand-hosts</code>) how simple names are qualified in DNS.</p>
<label class="form-label">Edit as raw line</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-raw")"
checked="@_draftUseRaw"
@onchange="@(e => OnDraftUseRawChanged(e.Value is true))" />
<label class="form-check-label" for="@($"{anchorId}-raw")">Edit as single line (advanced)</label>
</div>
@if (_draftUseRaw)
{
<textarea class="form-control form-control-sm font-monospace" rows="2"
@bind="_draftRawLine" @bind:event="oninput"
placeholder="domain[,subnet|IP|interface[,local]]"></textarea>
}
else
{
<div>
<label class="form-label" for="@($"{anchorId}-dom")">Domain</label>
<input id="@($"{anchorId}-dom")" class="form-control form-control-sm"
placeholder="e.g. home.arpa or #"
@bind="_draftDomain" @bind:event="oninput" />
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
</div>
<div>
<label class="form-label" for="@($"{anchorId}-scope")">Applies to</label>
<select id="@($"{anchorId}-scope")" class="form-select form-select-sm"
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
<option value="unconditional">Any client address (default)</option>
<option value="range">Limit to this subnet or IP</option>
<option value="interface">Limit to subnets on this interface</option>
</select>
</div>
@if (_draftScopeSelect == "range")
{
<div>
<label class="form-label" for="@($"{anchorId}-mid")">Subnet or IP</label>
<input id="@($"{anchorId}-mid")" class="form-control form-control-sm"
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
@bind="_draftMiddle" @bind:event="oninput" />
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc")"
@bind="_draftAddLocal" />
<label class="form-check-label" for="@($"{anchorId}-loc")">
Also add matching <code>--local</code> forward and reverse DNS
</label>
</div>
}
else if (_draftScopeSelect == "interface")
{
<div>
<label class="form-label" for="@($"{anchorId}-if")">Interface</label>
<input id="@($"{anchorId}-if")" class="form-control form-control-sm"
placeholder="e.g. eth0 or br0"
@bind="_draftMiddle" @bind:event="oninput" />
</div>
}
else if (_draftScopeSelect == "unconditional")
{
<div class="form-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc2")"
@bind="_draftAddLocal" />
<label class="form-check-label" for="@($"{anchorId}-loc2")">
Append <code>,local</code> (same as a lone <code>--local</code> for this name; uncommon)
</label>
</div>
}
}
<div class="ec-domain-rule-actions">
<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" />
</div>
</div>
}
else
{
<div class="ec-domain-rule-summary">
<span class="ec-field-value-item">@DisplayLine(rowValue)</span>
@if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal))
{
<code class="text-muted small">@rowValue</code>
}
<span class="status-badge-inline">
<OptionStateBadge
AnchorId="@anchorId"
Source="@item.Source"
OptionName="@OptionName"
Value="@rowValue"
IsInteractive="@IsEditMode"
ShowEditableBadge="@(!itemReadonly)"
EditableAsButton="@(!itemReadonly)"
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
@if (!itemReadonly)
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
</div>
}
</li>
}
}
else if (!AllowAddManagedValue)
{
<li class="ec-multi-list-row ec-multi-list-empty-hint">
<span class="text-muted">(none)</span>
</li>
}
@if (AllowAddManagedValue)
{
<li class="ec-multi-list-row ec-multi-list-add-item @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
@if (_addingNew)
{
<div class="ec-domain-rule-form">
<p class="ec-domain-rule-intro">Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with <code>expand-hosts</code>) how simple names are qualified in DNS.</p>
<label class="form-label">Edit as raw line</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="domain-add-raw"
checked="@_draftUseRaw"
@onchange="@(e => OnDraftUseRawChanged(e.Value is true))" />
<label class="form-check-label" for="domain-add-raw">Edit as single line (advanced)</label>
</div>
@if (_draftUseRaw)
{
<textarea class="form-control form-control-sm font-monospace" rows="2"
@bind="_draftRawLine" @bind:event="oninput"
placeholder="domain[,subnet|IP|interface[,local]]"></textarea>
}
else
{
<div>
<label class="form-label" for="domain-add-dom">Domain</label>
<input id="domain-add-dom" class="form-control form-control-sm"
placeholder="e.g. home.arpa or #"
@bind="_draftDomain" @bind:event="oninput" />
</div>
<div>
<label class="form-label" for="domain-add-scope">Applies to</label>
<select id="domain-add-scope" class="form-select form-select-sm"
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
<option value="unconditional">Any client address (default)</option>
<option value="range">Limit to this subnet or IP</option>
<option value="interface">Limit to subnets on this interface</option>
</select>
</div>
@if (_draftScopeSelect == "range")
{
<div>
<label class="form-label" for="domain-add-mid">Subnet or IP</label>
<input id="domain-add-mid" class="form-control form-control-sm"
placeholder="e.g. 192.168.0.0/24"
@bind="_draftMiddle" @bind:event="oninput" />
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="domain-add-loc"
@bind="_draftAddLocal" />
<label class="form-check-label" for="domain-add-loc">
Also add matching <code>--local</code> forward and reverse DNS
</label>
</div>
}
else if (_draftScopeSelect == "interface")
{
<div>
<label class="form-label" for="domain-add-if">Interface</label>
<input id="domain-add-if" class="form-control form-control-sm"
placeholder="e.g. eth0"
@bind="_draftMiddle" @bind:event="oninput" />
</div>
}
else if (_draftScopeSelect == "unconditional")
{
<div class="form-check">
<input type="checkbox" class="form-check-input" id="domain-add-loc2"
@bind="_draftAddLocal" />
<label class="form-check-label" for="domain-add-loc2">
Append <code>,local</code> (uncommon)
</label>
</div>
}
}
<div class="ec-domain-rule-actions">
<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" />
</div>
</div>
}
else
{
<StatusBadge Kind="StatusBadgeKind.Editable" Text="add item" Clickable="true"
Title="Add a new value" AriaLabel="Add item"
OnClick="BeginAdd" Class="status-badge-inline ec-multi-list-add-btn" />
}
</li>
}
</ul>
</div>
@code {
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
[Parameter] public string OptionName { get; set; } = "domain";
[Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = "";
[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 = "domain-add-item";
private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
private int? _editingIndex;
private bool _addingNew;
private bool _didAutoOpenAdd;
private string? _lastValidationErrorMessage;
private DateTime _lastValidationErrorUtc;
private List<ValueWithSource> _displayItems = new();
private bool _draftUseRaw;
private string _draftRawLine = "";
private string _draftDomain = "";
private string _draftScopeSelect = "unconditional";
private string _draftMiddle = "";
private bool _draftAddLocal;
private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList();
protected override void OnParametersSet()
{
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue)
{
_didAutoOpenAdd = true;
_addingNew = true;
ResetDraftForNew();
}
}
private static string DisplayLine(string rowValue) =>
DomainRuleFields.FormatSummary(rowValue);
private void ResetDraftForNew()
{
_draftUseRaw = false;
_draftRawLine = "";
_draftDomain = "";
_draftScopeSelect = "unconditional";
_draftMiddle = "";
_draftAddLocal = false;
}
private void BeginEdit(int index, string current)
{
_addingNew = false;
_editingIndex = index;
LoadDraftFromLine(current);
}
private void BeginAdd()
{
_editingIndex = null;
_addingNew = true;
ResetDraftForNew();
}
private void CancelInline()
{
_editingIndex = null;
_addingNew = false;
_lastValidationErrorMessage = null;
ResetDraftForNew();
}
private void LoadDraftFromLine(string line)
{
var parsed = DomainRuleFields.Parse(line);
if (parsed.IsRaw)
{
_draftUseRaw = true;
_draftRawLine = parsed.RawLineFallback ?? line;
_draftDomain = "";
_draftScopeSelect = "unconditional";
_draftMiddle = "";
_draftAddLocal = false;
return;
}
_draftUseRaw = false;
_draftRawLine = "";
_draftDomain = parsed.DomainPart;
_draftScopeSelect = parsed.Scope switch
{
DomainRuleScope.AddressRangeOrIp => "range",
DomainRuleScope.InterfaceName => "interface",
_ => "unconditional",
};
_draftMiddle = parsed.MiddlePart;
_draftAddLocal = parsed.AddLocalForRange;
}
private void OnDraftUseRawChanged(bool useRaw)
{
if (useRaw && !_draftUseRaw)
{
var fields = BuildFieldsFromDraftStructured();
_draftRawLine = fields.ToConfigLine();
}
else if (!useRaw && _draftUseRaw)
LoadDraftFromLine(_draftRawLine.Trim());
_draftUseRaw = useRaw;
}
private void OnDraftScopeAfterChange()
{
if (_draftScopeSelect == "unconditional")
_draftMiddle = "";
if (_draftScopeSelect == "interface")
_draftAddLocal = false;
}
private DomainRuleFields BuildFieldsFromDraftStructured()
{
var dom = (_draftDomain ?? "").Trim();
var mid = (_draftMiddle ?? "").Trim();
var scope = _draftScopeSelect switch
{
"range" => DomainRuleScope.AddressRangeOrIp,
"interface" => DomainRuleScope.InterfaceName,
_ => DomainRuleScope.Unconditional,
};
if (scope == DomainRuleScope.Unconditional)
return new DomainRuleFields(dom, DomainRuleScope.Unconditional, "", _draftAddLocal, null);
if (scope == DomainRuleScope.InterfaceName)
return new DomainRuleFields(dom, DomainRuleScope.InterfaceName, mid, false, null);
if (mid.Length == 0 && _draftAddLocal)
return new DomainRuleFields(dom, DomainRuleScope.Unconditional, "", true, null);
return new DomainRuleFields(dom, DomainRuleScope.AddressRangeOrIp, mid, _draftAddLocal, null);
}
private DomainRuleFields BuildFieldsFromDraft() =>
_draftUseRaw ? DomainRuleFields.FromRawConfigLine(_draftRawLine) : BuildFieldsFromDraftStructured();
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)
{
var next = _currentValues.ToList();
if (index < 0 || index >= next.Count) return;
var fields = BuildFieldsFromDraft();
var normalized = fields.ToConfigLine();
if (string.IsNullOrWhiteSpace(normalized))
{
ShowValidationError("Domain name (or #) is required.");
return;
}
if (!_draftUseRaw && _draftScopeSelect == "interface" && string.IsNullOrWhiteSpace(_draftMiddle))
{
ShowValidationError("Interface name is required for this scope.");
return;
}
normalized = Behavior?.Normalize(normalized) ?? normalized.Trim();
var validationError = Validator?.ValidateItem(normalized, _currentValues, index);
if (validationError != null)
{
ShowValidationError(validationError);
return;
}
if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal)))
{
ShowValidationError("Duplicate values are not allowed.");
return;
}
next[index] = normalized;
_displayItems[index] = _displayItems[index] with { Value = normalized };
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task SaveNew()
{
var fields = BuildFieldsFromDraft();
var normalized = fields.ToConfigLine();
if (string.IsNullOrWhiteSpace(normalized))
{
ShowValidationError("Domain name (or #) is required.");
return;
}
if (!_draftUseRaw && _draftScopeSelect == "interface" && string.IsNullOrWhiteSpace(_draftMiddle))
{
ShowValidationError("Interface name is required for this scope.");
return;
}
normalized = Behavior?.Normalize(normalized) ?? normalized.Trim();
var current = _currentValues;
var validationError = Validator?.ValidateItem(normalized, current, null);
if (validationError != null)
{
ShowValidationError(validationError);
return;
}
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
{
ShowValidationError("Duplicate values are not allowed.");
return;
}
var next = current.ToList();
next.Add(normalized);
_displayItems.Add(new ValueWithSource(normalized, null));
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task RemoveAt(int index)
{
if (index < 0 || index >= _displayItems.Count) return;
var item = _displayItems[index];
if (item?.Source?.IsReadOnly == true) return;
var next = _displayItems.Select(x => x.Value).ToList();
next.RemoveAt(index);
_displayItems.RemoveAt(index);
await OnValuesChanged.InvokeAsync(next);
StateHasChanged();
}
}
@@ -0,0 +1,79 @@
.ec-domain-rule-editor {
display: block;
padding: 0.75rem 1rem;
margin-top: 0.25rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
background-color: var(--bs-body-bg, #fff);
}
.ec-domain-rule-editor .ec-field-value-list,
.ec-domain-rule-list {
list-style: none;
margin-bottom: 0;
padding-left: 0;
}
.ec-domain-rule-list > li {
list-style: none;
}
.ec-domain-rule-intro {
font-size: 0.8rem;
color: var(--bs-secondary-color, #6c757d);
margin-bottom: 0.35rem;
line-height: 1.35;
}
.ec-domain-rule-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 2rem;
}
.ec-domain-rule-row.ec-domain-rule-row-editing {
align-items: stretch;
}
.ec-domain-rule-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 36rem;
margin-left: 0.5rem;
}
.ec-domain-rule-form .form-label {
margin-bottom: 0.15rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary-color, #6c757d);
}
.ec-domain-rule-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
}
.ec-domain-rule-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
margin-left: 0.5rem;
}
.ec-domain-rule-raw {
font-family: var(--bs-font-monospace);
font-size: 0.85rem;
}
.ec-domain-rule-hint {
font-size: 0.75rem;
color: var(--bs-secondary-color, #6c757d);
margin-top: -0.25rem;
}
@@ -0,0 +1,88 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
@inherits EffectiveConfigMultiValueDisplayBase
@if (Context.IsActiveEditor)
{
<DomainEffectiveConfigListEditor
SourceItems="EffectiveItems"
OptionName="@Context.Descriptor.OptionName"
ReadonlyBadgeAnchorIdBase="@_anchorBase"
IsEditMode="@Context.IsEditMode"
AllowAddManagedValue="@Context.IsEditMode"
FieldKey="@Context.FieldKey"
Behavior="@_multiDesc?.Behavior"
Validator="@_multiDesc?.Validator"
OnValuesChanged="@NotifyValuesChanged" />
}
else
{
<div class="ec-field-value ec-field-value-multi">
<ol class="ec-field-value-list">
@if (Context.EffectiveItems.Count > 0)
{
@foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index)))
{
var anchorId = $"{_anchorBase}-{index}";
<li>
<EffectiveConfigMultiValueItem
Value="@item.Value"
DisplayValue="@DomainRuleFields.FormatSummary(item.Value)"
Source="@item.Source"
OptionName="@Context.Descriptor.OptionName"
IsEditMode="@Context.IsEditMode"
ReadonlyBadgeAnchorId="@anchorId"
FieldKey="@Context.FieldKey" />
</li>
}
}
@if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
{
<li class="ec-multi-list-entry-point">
<OptionStateBadge
OptionName="@Context.Descriptor.OptionName"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@Context.FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@Context.HasPendingOrDraft"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
</li>
}
else if (Context.EffectiveItems.Count == 0)
{
<li class="ec-multi-list-empty-hint">
@if (Context.ShowEditableBadge)
{
<span class="text-muted">(none)</span>
<OptionStateBadge
OptionName="@Context.Descriptor.OptionName"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@Context.FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@Context.HasPendingOrDraft"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
}
else
{
<span class="text-muted">(none)</span>
}
</li>
}
</ol>
</div>
}
@code {
private readonly string _anchorBase = "ec-domain-" + Guid.NewGuid().ToString("N")[..8];
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc =>
Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor;
}
@@ -45,30 +45,32 @@
else if (Descriptor.IsMultiValue)
{
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
if (customMulti is not null)
if (customMulti is not null && _multiContext 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 && !Descriptor.IsCapabilityDisabled ? "ec-field-editing" : "")">
@* Custom multi: row badge only while this field is active (ok/cancel/revert). Otherwise per-item / entry badges in the fragment suffice — avoids duplicating "readonly" when RowSource matches item sources. *@
var customMultiActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
<li class="ec-field @(customMultiActiveEditor ? "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 && !Descriptor.IsCapabilityDisabled)">
<CascadingValue Value="@customMultiActiveEditor">
<CascadingValue Value="@IsEditMode">
@customMulti(Descriptor)
@customMulti(_multiContext)
</CascadingValue>
</CascadingValue>
</span>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
@if (customMultiActiveEditor)
{
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiContext.RowSource" OptionName="@Descriptor.OptionName" Value="@_multiContext.EffectiveItems"
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiContext.HasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
</li>
}
else
else if (_multiContext is not null)
{
@* 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 && !Descriptor.IsCapabilityDisabled)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@_multiValueRowSource" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
<EffectiveConfigMultiValueRow Context="@_multiContext" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
}
@@ -122,14 +124,7 @@ else
private bool _wasActive;
private ExitAction _exitAction = ExitAction.None;
/// <summary>Cached for multi-value: items to pass to the row (with sources preserved for draft merge).</summary>
private IReadOnlyList<ValueWithSource>? _multiValueItems;
/// <summary>Cached for multi-value: true when there is an uncommitted draft list.</summary>
private bool _multiValueHasDraft;
/// <summary>Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode).</summary>
private ConfigValueSource? _multiValueRowSource;
/// <summary>Cached for multi-value: whether row should show ok/cancel (session pending or local draft).</summary>
private bool _multiValueHasPendingOrDraft;
private EffectiveConfigMultiDisplayContext? _multiContext;
protected override void OnParametersSet()
{
@@ -146,7 +141,15 @@ else
_hasPendingChange = pending != null;
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
_multiContext = EffectiveConfigMultiDisplayContext.From(
Descriptor,
Descriptor.GetItems(),
pending?.NewValue as IReadOnlyList<string>,
_hasPendingChange,
_draftValue as IReadOnlyList<string>,
IsEditMode,
IsActive,
FieldKey);
if (!_wasActive && IsActive)
{
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
@@ -161,45 +164,6 @@ else
_wasActive = IsActive;
}
private void ComputeMultiValueState(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string>? pendingList, IReadOnlyList<string>? draftList)
{
var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List<string>();
_multiValueHasDraft = draftList != null && !ValuesEqual(descriptorValues, draftList);
if (_multiValueHasDraft && draftList != null)
{
_multiValueItems = BuildItemsWithSource(descriptorItems, draftList);
}
else if (_hasPendingChange && pendingList != null)
{
_multiValueItems = BuildItemsWithSource(descriptorItems, pendingList);
}
else
{
_multiValueItems = descriptorItems;
}
var allReadonly = _multiValueItems != null && _multiValueItems.Count > 0 && _multiValueItems.All(i => i.Source?.IsReadOnly == true);
_multiValueRowSource = allReadonly && !IsEditMode ? _multiValueItems?.FirstOrDefault()?.Source : null;
_multiValueHasPendingOrDraft = _hasPendingChange || _multiValueHasDraft;
}
private static IReadOnlyList<ValueWithSource> BuildItemsWithSource(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string> values)
{
var pool = descriptorItems?.ToList() ?? new List<ValueWithSource>();
var list = new List<ValueWithSource>(values.Count);
foreach (var s in values)
{
var idx = pool.FindIndex(x => string.Equals(x.Value, s, StringComparison.Ordinal));
if (idx >= 0)
{
list.Add(pool[idx]);
pool.RemoveAt(idx);
}
else
list.Add(new ValueWithSource(s, null));
}
return list;
}
private void HandleValueChanged(object? value)
{
_draftValue = value;
@@ -279,7 +243,20 @@ else
_draftValue = values.ToList();
_hasDraftValue = true;
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
{
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
_multiContext = EffectiveConfigMultiDisplayContext.From(
Descriptor,
Descriptor.GetItems(),
pending?.NewValue as IReadOnlyList<string>,
pending != null,
_draftValue as IReadOnlyList<string>,
IsEditMode,
IsActive,
FieldKey);
}
StateHasChanged();
}
@@ -3,7 +3,7 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<span class="ec-field-value-item" title="@Source?.GetReadOnlyTooltip()">
@Value
@(DisplayValue ?? Value)
<OptionStateBadge
AnchorId="@(ReadonlyBadgeAnchorId ?? "")"
Source="@Source"
@@ -16,7 +16,10 @@
</span>
@code {
/// <summary>Raw config line for badges / popovers.</summary>
[Parameter] public string Value { get; set; } = "";
/// <summary>When set, shown instead of <see cref="Value"/> in the value span (e.g. friendly summary).</summary>
[Parameter] public string? DisplayValue { get; set; }
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; }
@@ -4,7 +4,6 @@
@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">
@@ -3,32 +3,32 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field">
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
@if (IsActiveEditor)
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Context.Descriptor.OptionName" Title="@LabelTooltip"><strong>@Context.Descriptor.OptionName:</strong></OptionHelpLabel>
@if (Context.IsActiveEditor)
{
<EffectiveConfigMultiValueListEditor
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
OptionName="@Label"
SourceItems="@(Context.EffectiveItems)"
OptionName="@Context.Descriptor.OptionName"
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
IsEditMode="@IsEditMode"
AllowAddManagedValue="@IsEditMode"
FieldKey="@FieldKey"
Behavior="@(_multiDesc?.Behavior)"
Validator="@(_multiDesc?.Validator)"
IsEditMode="@Context.IsEditMode"
AllowAddManagedValue="@Context.IsEditMode"
FieldKey="@Context.FieldKey"
Behavior="@(MultiDesc?.Behavior)"
Validator="@(MultiDesc?.Validator)"
OnValuesChanged="@ItemsChanged" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason"
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Context.RowSource" OptionName="@Context.Descriptor.OptionName" Value="@Context.EffectiveItems"
IsInteractive="@Context.IsEditMode" ShowEditableBadge="@Context.ShowEditableBadge" HasPendingChange="@Context.HasPendingOrDraft"
EditableAsButton="true" FieldKey="@Context.FieldKey" IsActiveEditor="@Context.IsActiveEditor"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
}
else
{
<div class="ec-field-value ec-field-value-multi">
<ol class="ec-field-value-list">
@if (Items?.Count > 0)
@if (Context.EffectiveItems.Count > 0)
{
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
@foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index)))
{
var anchorId = ReadonlyBadgeAnchorIdBase != null
? $"{ReadonlyBadgeAnchorIdBase}-{index}"
@@ -37,44 +37,44 @@
<EffectiveConfigMultiValueItem
Value="@item.Value"
Source="@item.Source"
OptionName="@Label"
IsEditMode="@IsEditMode"
OptionName="@Context.Descriptor.OptionName"
IsEditMode="@Context.IsEditMode"
ReadonlyBadgeAnchorId="@anchorId"
FieldKey="@FieldKey" />
FieldKey="@Context.FieldKey" />
</li>
}
}
@if (ShowEditableBadge && (Items?.Count ?? 0) > 0)
@if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
{
<li class="ec-multi-list-entry-point">
<OptionStateBadge
OptionName="@Label"
OptionName="@Context.Descriptor.OptionName"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@FieldKey"
FieldKey="@Context.FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@HasPendingChange"
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)"
CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
HasPendingChange="@Context.HasPendingOrDraft"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
</li>
}
else if ((Items?.Count ?? 0) == 0)
else if (Context.EffectiveItems.Count == 0)
{
<li class="ec-multi-list-empty-hint">
@if (ShowEditableBadge)
@if (Context.ShowEditableBadge)
{
<span class="text-muted">(none)</span>
<OptionStateBadge
OptionName="@Label"
OptionName="@Context.Descriptor.OptionName"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@FieldKey"
FieldKey="@Context.FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@HasPendingChange"
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)"
CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
HasPendingChange="@Context.HasPendingOrDraft"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
}
else
{
@@ -88,23 +88,16 @@
</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 EffectiveConfigMultiDisplayContext Context { get; set; } = null!;
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
[Parameter] public bool IsActiveEditor { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public bool ShowEditableBadge { get; set; }
[Parameter] public bool HasPendingChange { get; set; }
[Parameter] public string FieldKey { get; set; } = "";
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public EventCallback OnConfirmRequested { get; set; }
[Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; }
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
private EffectiveMultiValueConfigFieldDescriptor? MultiDesc =>
Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor;
}
@@ -1,5 +1,6 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Extensions.DependencyInjection
@using DnsmasqWebUI.Extensions.Interop
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@@ -57,14 +58,8 @@
{
if (firstRender)
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
_moduleLoaded = true;
}
catch (InvalidOperationException) { /* prerender */ }
catch (JSDisconnectedException) { /* circuit disconnected */ }
catch (JSException) { /* import failed */ }
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
_moduleLoaded = _jsModule != null;
}
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
@@ -93,15 +88,21 @@
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded && _jsModule != null)
try
{
if (_scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
}
}
finally
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
protected override async Task OnParametersSetAsync()
@@ -1,6 +1,7 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Extensions
@using DnsmasqWebUI.Extensions.Interop
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@@ -76,14 +77,8 @@
{
if (firstRender)
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
_moduleLoaded = true;
}
catch (InvalidOperationException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
_moduleLoaded = _jsModule != null;
}
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
@@ -121,15 +116,21 @@
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded && _jsModule != null)
try
{
if (_scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
}
}
finally
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
private async Task PositionUnderAnchorAsync()