Effective config refactor: session, UI context, cascade cleanup, save modal z-index

- Add IEffectiveConfigEditSession + EffectiveConfigEditSession
- Add EffectiveConfigUiContext for option help / readonly popover / commit intents
- Extract OptionHelpController, ReadonlyPopoverController, EffectiveConfigSaveFlow,
  EffectiveConfigToolbar, EffectiveConfigPanels, CopyableCodeLine, OptionHelpLabel,
  ReadonlyBadge, EffectiveConfigValueDisplayBase
- Remove redundant named CascadingValues (IsEditMode, ActiveFieldKey, ManagedFilePath)
- Remove unused ManagedFilePath cascading parameter from EffectiveConfigFieldDisplay
- Fix save modal stacking: add EffectiveConfigSaveModal.razor.css (dialog above backdrop)
- Pending changes displayed before save; hover-close fix for option help and readonly popover
This commit is contained in:
2026-02-28 18:52:29 +10:00
parent f22b039e74
commit 6a7c0f62e1
27 changed files with 674 additions and 483 deletions
@@ -0,0 +1,27 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@inject IJSRuntime JSRuntime
<div class="d-flex align-items-center gap-1 @CssClass">
<code class="flex-grow-1 text-break @CodeCssClass">@Text</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle">Copy</button>
</div>
@code {
[Parameter] public string Text { get; set; } = "";
[Parameter] public string? CssClass { get; set; }
[Parameter] public string? CodeCssClass { get; set; }
[Parameter] public string CopyTitle { get; set; } = "Copy";
private async Task CopyAsync()
{
if (string.IsNullOrEmpty(Text)) return;
try
{
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
}
catch
{
// Clipboard not available or failed
}
}
}
@@ -1,25 +1,21 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". *@
@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". Pending value shown until save. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var isSet = Descriptor.GetValue() is bool b && b;
var isSet = GetEffectiveDisplayValue() is bool b && b;
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" @onfocusout="OnCommitRequested" />
}
else
{
@(Descriptor.GetValue() is bool isSet && isSet ? "Enabled" : "Disabled")
@(GetEffectiveDisplayValue() is bool isSet && isSet ? "Enabled" : "Disabled")
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private async Task OnCheckedChange(ChangeEventArgs e)
{
await ValueChanged.InvokeAsync(e.Value is true);
await NotifyValueChanged(e.Value is true);
}
}
@@ -1,16 +1,17 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* Value-only display for int? options. When active editor: number input (unless readonly); View: value + optional suffix/unit. Metadata from EffectiveIntegerConfigFieldDescriptor. *@
@* Value-only display for int? options. Pending value shown until save. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var current = Descriptor.GetValue() is int i ? i : DefaultValue;
var current = GetEffectiveDisplayValue() is int i ? i : DefaultValue;
<input type="number" class="form-control form-control-sm" style="max-width: 8rem;"
min="@Min" max="@Max" value="@(current?.ToString() ?? "")" @oninput="OnInput" @onfocusout="OnCommitRequested" />
}
else
{
var v = Descriptor.GetValue() is int n ? n : (int?)null;
var v = GetEffectiveDisplayValue() is int n ? n : (int?)null;
if (v is null)
{
@("(not set)")
@@ -24,11 +25,6 @@ else
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private EffectiveIntegerConfigFieldDescriptor? IntDescriptor => Descriptor as EffectiveIntegerConfigFieldDescriptor;
private string? ViewSuffix => IntDescriptor?.ViewSuffix;
private string? Unit => IntDescriptor?.Unit;
@@ -41,6 +37,6 @@ else
int? value = null;
if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max)
value = num;
await ValueChanged.InvokeAsync(value);
await NotifyValueChanged(value);
}
}
@@ -1,10 +1,11 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* local-service: net | host. View: (not set) / net / host. When active editor: dropdown. *@
@* local-service: net | host. Pending value shown until save. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(Descriptor.GetValue() as string ?? "")" @onchange="OnChange" @onfocusout="OnCommitRequested">
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(GetEffectiveDisplayValue() as string ?? "")" @onchange="OnChange" @onfocusout="OnCommitRequested">
<option value="">(not set)</option>
<option value="net">net</option>
<option value="host">host</option>
@@ -12,20 +13,15 @@
}
else
{
var v = Descriptor.GetValue() as string;
var v = GetEffectiveDisplayValue() as string;
@(string.IsNullOrEmpty(v) ? "(not set)" : (v.Trim().ToLowerInvariant() is "net" or "host" ? v.Trim() : v))
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private async Task OnChange(ChangeEventArgs e)
{
var v = e.Value?.ToString()?.Trim();
var value = string.IsNullOrEmpty(v) ? null : (v == "net" || v == "host" ? v : null);
await ValueChanged.InvokeAsync(value);
await NotifyValueChanged(value);
}
}
@@ -1,5 +1,6 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* log-queries: optional value extra|proto|auth. View: Off / On / On (extra|proto|auth). When active editor: dropdown. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
@@ -18,14 +19,9 @@ else
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private string GetEditValue()
{
var v = Descriptor.GetValue() as string;
var v = GetEffectiveDisplayValue() as string;
if (string.IsNullOrEmpty(v)) return "";
var lower = v.Trim().ToLowerInvariant();
if (lower is "extra" or "proto" or "auth") return lower;
@@ -34,7 +30,7 @@ else
private string GetViewText()
{
var v = Descriptor.GetValue() as string;
var v = GetEffectiveDisplayValue() as string;
if (string.IsNullOrEmpty(v)) return "Off";
var lower = v.Trim().ToLowerInvariant();
return lower switch
@@ -58,6 +54,6 @@ else
"auth" => "auth",
_ => null
};
await ValueChanged.InvokeAsync(newValue);
await NotifyValueChanged(newValue);
}
}
@@ -1,28 +1,24 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* Value-only display for the port option. IsActiveEditor is cascaded from FieldDisplay (true only for the single active field in edit mode). When active editor and not readonly: number input; else: view text. *@
@* Value-only display for the port option. Pending value shown until save. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var port = Descriptor.GetValue() is int p ? p : 53;
var port = GetEffectiveDisplayValue() is int p ? p : 53;
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" @onfocusout="OnCommitRequested" />
}
else
{
@(Descriptor.GetValue() is int port ? $"{port} (default DNS port)" : "(not set)")
@(GetEffectiveDisplayValue() is int port ? $"{port} (default DNS port)" : "(not set)")
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private async Task OnPortInput(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
await ValueChanged.InvokeAsync(port);
await NotifyValueChanged(port);
else
await ValueChanged.InvokeAsync(null);
await NotifyValueChanged(null);
}
}
@@ -6,7 +6,7 @@
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. *@
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@
@if (_customFragment is { } fragment)
{
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
@@ -14,10 +14,10 @@
var showEditableBadge = !isReadonly;
var isActiveEditor = IsEditMode && IsActive;
var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null;
var effectiveValue = _effectiveValue ?? Descriptor.GetValue();
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<span id="@_anchorId" class="ec-field-label @(_optionHelpKey != null ? "ec-field-label-help" : null)" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Descriptor.OptionName:</strong></span>
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" CssClass="@(_optionHelpKey != null ? "ec-field-label ec-field-label-help" : "ec-field-label")" Title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
<span class="ec-field-value">
@* IsActiveEditor: cascaded from here (true only for this field when it's the active editor). Section cascades IsEditMode + ActiveFieldKey; Panel passes IsActive=(ActiveFieldKey==fieldKey); we derive isActiveEditor = IsEditMode && IsActive and cascade that bool so fragment children (e.g. PortValueDisplay) know whether to show the input. *@
@if (isActiveEditor)
{
<div class="ec-field-edit-wrapper" @onfocusout="OnBlur" @onclick:stopPropagation="true">
@@ -34,20 +34,10 @@
@fragment(Descriptor)
</CascadingValue>
}
@if (isReadonly)
{
@if (IsEditMode)
{
<button type="button" id="@_readonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseReadonlyBadgeClicked" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">readonly</span>
}
}
<ReadonlyBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" />
@if (showEditableBadge)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
<button type="button" class="badge ec-badge-inline border-0 ms-1 @(_hasPendingChange ? "bg-warning text-dark" : "bg-primary")" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="@(_hasPendingChange ? "Pending change" : "Edit this field")" aria-label="Edit">@(_hasPendingChange ? "pending" : "editable")</button>
}
</span>
</li>
@@ -55,11 +45,11 @@
else if (Descriptor.IsMultiValue)
{
var items = Descriptor.GetItems();
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" OnReadonlyBadgeClicked="@OnReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" />
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" />
}
else
{
var value = Descriptor.GetValue();
var value = _effectiveValue ?? Descriptor.GetValue();
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
var showReadonlyHint = IsEditMode && isReadonly;
@@ -67,16 +57,18 @@ else
@if (showReadonlyHint)
{
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" SuppressOuterLi="true"
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" OnReadonlyBadgeClick="@RaiseReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" />
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange" />
</li>
}
else
{
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested"
ShowEditableBadge="true" OnActivateEdit="@OnActivateEdit" IsActive="@IsActive"
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" OnReadonlyBadgeClick="@RaiseReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave"
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" />
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
ShowEditableBadge="true" OnActivateEdit="@OnActivateEdit" IsActive="@IsActive"
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" />
</li>
}
}
@@ -87,10 +79,7 @@ else
[Parameter] public string? ActiveFieldKey { get; set; }
[Parameter] public EventCallback OnActivateEdit { get; set; }
[Parameter] public EventCallback<EffectiveConfigEditCommittedArgs> OnEditCommitted { get; set; }
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
[CascadingParameter] public string? ManagedFilePath { get; set; }
[CascadingParameter] public IEffectiveConfigEditSession? Session { get; set; }
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
private readonly string _readonlyBadgeAnchorId = "ec-readonly-" + Guid.NewGuid().ToString("N")[..8];
@@ -98,6 +87,8 @@ else
private RenderFragment<EffectiveConfigFieldDescriptor>? _fragmentWithCallback;
private string? _labelTooltip;
private string? _optionHelpKey;
private object? _effectiveValue;
private bool _hasPendingChange;
private object? _valueAtActivation;
private object? _draftValue;
private bool _wasActive;
@@ -108,8 +99,13 @@ else
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
var pending = Session?.PendingChanges.FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
_hasPendingChange = pending != null;
_effectiveValue = pending?.NewValue ?? Descriptor.GetValue();
if (!_wasActive && IsActive)
_valueAtActivation = Descriptor.GetValue();
_valueAtActivation = _effectiveValue;
if (_wasActive && !IsActive)
_ = InvokeAsync(CommitIfChangedAsync);
_wasActive = IsActive;
@@ -128,21 +124,20 @@ else
private async Task CommitIfChangedAsync()
{
var newValue = _draftValue ?? Descriptor.GetValue();
var newValue = _draftValue ?? _effectiveValue ?? Descriptor.GetValue();
if (!Equals(_valueAtActivation, newValue))
{
var source = Descriptor.GetSource();
await OnEditCommitted.InvokeAsync(new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath));
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath);
if (Ui != null)
await Ui.CommitFieldAsync(args);
else if (OnEditCommitted.HasDelegate)
await OnEditCommitted.InvokeAsync(args);
}
_draftValue = null;
}
private async Task RaiseReadonlyBadgeClicked()
{
var source = Descriptor.GetSource();
if (source == null) return;
await OnReadonlyBadgeClicked.InvokeAsync(new ReadonlyBadgeClickedEventArgs(_readonlyBadgeAnchorId, source, Descriptor.OptionName, Descriptor.GetValue()));
}
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
private void HandleDefaultValueChanged(string value)
{
@@ -156,11 +151,4 @@ else
await CommitIfChangedAsync();
}
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(_optionHelpKey)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = _optionHelpKey, OptionLabel = Descriptor.OptionName, AnchorId = _anchorId });
}
private async Task RaiseLabelMouseLeave() => await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = _anchorId, IsLabelMouseLeave = true });
}
@@ -4,18 +4,8 @@
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
@Value
@if (Source?.IsReadOnly == true)
{
@if (IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
{
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1">readonly</span>
}
}
else if (IsEditMode)
<ReadonlyBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" />
@if (Source?.IsReadOnly != true && IsEditMode)
{
<span class="badge bg-primary ec-badge-inline ms-1">editable</span>
}
@@ -24,8 +14,7 @@
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeClick { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
}
@@ -3,7 +3,7 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field">
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@LabelTooltip" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" CssClass="@(OptionHelpKey != null ? "ec-field-label ec-field-label-help" : "ec-field-label")" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
@if (IsActiveEditor)
{
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true">
@@ -18,9 +18,8 @@
<ol class="ec-field-value-list">
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
{
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : null;
var args = new ReadonlyBadgeClickedEventArgs(anchorId ?? "", item.Source!, Label, item.Value);
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" OnReadonlyBadgeClick="@(() => _ = OnReadonlyBadgeClicked.InvokeAsync(args))" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" /></li>
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" /></li>
}
</ol>
}
@@ -42,24 +41,9 @@
[Parameter] public bool IsActiveEditor { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private string _editLines = "";
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
}
private async Task RaiseLabelMouseLeave()
{
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
}
protected override void OnParametersSet()
{
if (Items != null && Items.Count > 0)
@@ -2,95 +2,45 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@if (SuppressOuterLi)
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" CssClass="@(OptionHelpKey != null ? "ec-field-label ec-field-label-help" : "ec-field-label")" Title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></OptionHelpLabel>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
{
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
{
<span class="ec-field-value" @onclick:stopPropagation="true">
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
@if (ShowEditableBadge)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
}
else
{
<span class="ec-field-value">
@DisplayValue
@if (Source?.IsReadOnly == true)
{
@if (!string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
{
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Source?.GetReadOnlyTooltip())">readonly</span>
}
}
@if (ShowEditableBadge && Source?.IsReadOnly != true)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
}
<span class="ec-field-value" @onclick:stopPropagation="true">
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
@if (ShowEditableBadge)
{
<button type="button" class="badge ec-badge-inline border-0 ms-1 @(HasPendingChange ? "bg-warning text-dark" : "bg-primary")" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="@(HasPendingChange ? "Pending change" : "Edit this field")" aria-label="Edit">@(HasPendingChange ? "pending" : "editable")</button>
}
</span>
}
else
{
<li class="ec-field @(IsActive ? "ec-field-editing" : "")" title="@(Source?.GetReadOnlyTooltip())">
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
<span class="ec-field-value">
@DisplayValue
<ReadonlyBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" />
@if (ShowEditableBadge && Source?.IsReadOnly != true)
{
<span class="ec-field-value" @onclick:stopPropagation="true">
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
@if (ShowEditableBadge)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
<button type="button" class="badge ec-badge-inline border-0 ms-1 @(HasPendingChange ? "bg-warning text-dark" : "bg-primary")" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="@(HasPendingChange ? "Pending change" : "Edit this field")" aria-label="Edit">@(HasPendingChange ? "pending" : "editable")</button>
}
else
{
<span class="ec-field-value">
@DisplayValue
@if (Source?.IsReadOnly == true)
{
@if (!string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
{
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Source?.GetReadOnlyTooltip())">readonly</span>
}
}
@if (ShowEditableBadge && Source?.IsReadOnly != true)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
}
</li>
</span>
}
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public string DisplayValue { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
/// <summary>When true, render only the inner content (no outer li); used when parent wraps with ReadonlyEditHint.</summary>
[Parameter] public bool SuppressOuterLi { get; set; }
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
/// <summary>When set with OnReadonlyBadgeClick, the readonly badge is rendered as a button with this id so the parent can open a popover under it.</summary>
/// <summary>When set in edit mode, the readonly badge is rendered as a button with this id so the popover can anchor to it.</summary>
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeClick { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
/// <summary>Raw value for the readonly popover override line (e.g. from descriptor); falls back to DisplayValue.</summary>
[Parameter] public object? RawValue { get; set; }
/// <summary>When true, show an 'editable' badge next to non-readonly fields; clicking it activates edit for this field.</summary>
[Parameter] public bool ShowEditableBadge { get; set; }
/// <summary>When true, badge shows "pending" and warning style to indicate this field has an unsaved change.</summary>
[Parameter] public bool HasPendingChange { get; set; }
[Parameter] public EventCallback OnActivateEdit { get; set; }
/// <summary>When true, add ec-field-editing class and show input when DisplayMode is Edit.</summary>
[Parameter] public bool IsActive { get; set; }
@@ -99,19 +49,6 @@ else
/// <summary>When Edit and not readonly: raised when the input loses focus; argument is the current value.</summary>
[Parameter] public EventCallback<string?> OnBlur { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
}
private async Task RaiseLabelMouseLeave()
{
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
}
private string? _editValue;
@@ -0,0 +1,50 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
<div class="ec-sections small">
@{
var views = EffectiveConfigViews.GetViewsForContext(Context);
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
var term = SearchTerm?.Trim() ?? "";
}
@foreach (var view in views)
{
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
continue;
var sectionDescriptors = hasSearch
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
: contextDescriptors.ToList();
if (sectionDescriptors.Count == 0)
continue;
var isOpen = hasSearch || IsSectionOpen(view.SectionId);
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle"
DisplayMode="@( IsEditMode ? EffectiveConfigDisplayMode.Edit : EffectiveConfigDisplayMode.View )"
IsEditMode="@IsEditMode" ActiveFieldKey="@ActiveFieldKey"
OnActivateField="@OnActivateField" OnFieldEditCommitted="@OnFieldEditCommitted" />
}
</div>
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
[Parameter] public string SearchTerm { get; set; } = "";
[Parameter] public HashSet<string> OpenSectionIds { get; set; } = new();
[Parameter] public EventCallback<string> OnSectionToggle { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ActiveFieldKey { get; set; }
[Parameter] public EventCallback<string> OnActivateField { get; set; }
[Parameter] public EventCallback<EffectiveConfigEditCommittedArgs> OnFieldEditCommitted { get; set; }
private bool IsSectionOpen(string id) => OpenSectionIds.Contains(id);
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
{
if (string.IsNullOrEmpty(term)) return true;
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,33 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@if (ShowModal)
{
<EffectiveConfigSaveModal PendingChanges="@PendingChanges" Status="@Status" OnClose="@OnClose" OnSave="@HandleSave" />
}
@code {
[Parameter] public bool ShowModal { get; set; }
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnSaveCompleted { get; set; }
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
private async Task HandleSave()
{
if (Session.PendingChanges.Count == 0) return;
try
{
await Session.ApplyAsync();
await OnClose.InvokeAsync();
await OnSaveCompleted.InvokeAsync();
}
catch (Exception)
{
// TODO: show error to user (e.g. toast or modal message)
}
}
}
@@ -0,0 +1,9 @@
/* Ensure the dialog stacks above the backdrop when both are siblings inside .modal */
.modal-dialog {
position: relative;
z-index: 1056;
}
.modal-backdrop {
z-index: 1050;
}
@@ -3,88 +3,26 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@inject DnsmasqWebUI.Infrastructure.Services.Abstractions.IDnsmasqConfigService ConfigService
@inject IEffectiveConfigEditSession Session
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (Status != null)
{
<div class="ec-container" @onclick="DeactivateActiveFieldIfClicked">
<ConfigOptionHelpModal HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Visible="@_showOptionHelp" Title="@_optionHelpTitle"
OnClose="CloseOptionHelp" OnMouseEnterModal="CancelCloseTimerAsync" OnMouseLeaveModal="ScheduleCloseAsync" />
<ReadonlyEditPopover Visible="@_showReadonlyPopover" AnchorId="@_readonlyPopoverAnchorId" Source="@_readonlyPopoverSource" OptionName="@(_readonlyPopoverOptionName ?? "")" Value="@_readonlyPopoverValue" ManagedFilePath="@(Status?.ManagedFilePath)"
OnClose="CloseReadonlyPopover" OnMouseEnterPopover="CancelReadonlyCloseTimerAsync" OnMouseLeavePopover="ScheduleReadonlyCloseAsync" />
<div class="ec-toolbar @(_isEditMode ? "ec-edit-toolbar" : "")">
@if (ShowSearchBox)
{
<input type="search" id="ec-search-input" class="form-control form-control-sm ec-toolbar-search" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" @bind:after="StateHasChanged" />
}
<div class="ec-toolbar-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ExpandAll" title="Expand all sections" aria-label="Expand all sections">
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
</button>
@if (_isEditMode)
{
<button type="button" class="btn btn-sm btn-primary" @onclick="OpenSaveModal" disabled="@(_pendingChanges.Count == 0)" title="Save changes to managed config" aria-label="Save">
<i class="bi bi-floppy-fill" aria-hidden="true"></i>
</button>
@if (_pendingChanges.Count > 0)
{
<span class="text-muted small">@_pendingChanges.Count change(s)</span>
}
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ExitEditModeWithConfirmAsync" title="Exit edit mode" aria-label="Exit edit mode">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</button>
}
else
{
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="EnterEditMode" title="Edit effective config" aria-label="Edit">
<i class="bi bi-pencil" aria-hidden="true"></i>
</button>
}
</div>
</div>
<CascadingValue Name="IsEditMode" Value="@_isEditMode">
<CascadingValue Name="ActiveFieldKey" Value="@_activeFieldKey">
<CascadingValue Name="ManagedFilePath" Value="@(Status?.ManagedFilePath)">
<div class="ec-sections small">
@{
var views = EffectiveConfigViews.GetViewsForContext(Context);
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
var hasSearch = !string.IsNullOrWhiteSpace(_searchTerm);
var term = _searchTerm?.Trim() ?? "";
}
@foreach (var view in views)
{
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
continue;
var sectionDescriptors = hasSearch
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
: contextDescriptors.ToList();
if (sectionDescriptors.Count == 0)
continue;
var isOpen = hasSearch ? true : IsOpen(view.SectionId);
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle"
DisplayMode="@( _isEditMode ? EffectiveConfigDisplayMode.Edit : EffectiveConfigDisplayMode.View )"
IsEditMode="@_isEditMode" ActiveFieldKey="@_activeFieldKey"
OnActivateField="ActivateField" OnFieldEditCommitted="HandleFieldEditCommitted"
OnReadonlyBadgeClicked="HandleReadonlyBadgeClicked"
OnReadonlyBadgeMouseLeave="ScheduleReadonlyCloseAsync"
OnOptionHelpRequested="@HandleOptionHelpRequested" />
}
</div>
<OptionHelpController @ref="_optionHelpControllerRef" Visible="@_showOptionHelp" HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Title="@_optionHelpTitle" OnCloseRequested="CloseOptionHelp" />
<ReadonlyPopoverController @ref="_readonlyPopoverControllerRef" Visible="@_showReadonlyPopover" AnchorId="@_readonlyPopoverAnchorId" Source="@_readonlyPopoverSource" OptionName="@(_readonlyPopoverOptionName ?? "")" Value="@_readonlyPopoverValue" ManagedFilePath="@(Status?.ManagedFilePath)" OnCloseRequested="CloseReadonlyPopover" />
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
<CascadingValue Value="@Session">
<CascadingValue Value="@_uiContext">
<EffectiveConfigPanels Status="@Status" Context="@Context" SearchTerm="@_searchTerm" OpenSectionIds="@_openPanels"
OnSectionToggle="OnSectionToggle" IsEditMode="@Session.IsEditMode" ActiveFieldKey="@Session.ActiveFieldKey"
OnActivateField="ActivateField" OnFieldEditCommitted="HandleFieldEditCommitted" />
</CascadingValue>
</CascadingValue>
</CascadingValue>
@if (_showSaveModal)
{
<EffectiveConfigSaveModal PendingChanges="_pendingChanges" Status="@Status" OnClose="CloseSaveModal" OnSave="ConfirmSave" />
}
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" />
</div>
}
@@ -92,139 +30,95 @@
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
[Parameter] public bool ShowSearchBox { get; set; } = true;
/// <summary>Raised after pending changes were successfully written to managed config (so the page can refresh status).</summary>
[Parameter] public EventCallback OnSaveCompleted { get; set; }
private string _searchTerm = "";
private HashSet<string> _openPanels = new();
private bool _showSaveModal;
private string? _optionHelpKey;
private string? _optionHelpAnchorId;
private string _optionHelpTitle = "Option help";
private bool _showOptionHelp;
private CancellationTokenSource? _closeCts;
private bool _isEditMode;
private string? _activeFieldKey;
private readonly List<PendingEffectiveConfigChange> _pendingChanges = new();
private bool _showSaveModal;
private OptionHelpController? _optionHelpControllerRef;
private bool _showReadonlyPopover;
private ReadonlyPopoverController? _readonlyPopoverControllerRef;
private string? _readonlyPopoverAnchorId;
private ConfigValueSource? _readonlyPopoverSource;
private string? _readonlyPopoverOptionName;
private object? _readonlyPopoverValue;
private CancellationTokenSource? _readonlyCloseCts;
private bool IsOpen(string id) => _openPanels.Contains(id);
private EffectiveConfigUiContext? _uiContext;
protected override void OnInitialized()
{
_uiContext = new EffectiveConfigUiContext
{
RequestOptionHelpAsync = HandleOptionHelpRequested,
RequestReadonlyPopoverAsync = HandleReadonlyBadgeClicked,
ScheduleReadonlyPopoverCloseAsync = () =>
{
_readonlyPopoverControllerRef?.ScheduleCloseFromBadgeLeave();
return Task.CompletedTask;
},
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; }
};
}
private async Task OnSearchTermChanged(string value)
{
_searchTerm = value ?? "";
await InvokeAsync(StateHasChanged);
}
private void CloseOptionHelp()
{
_showOptionHelp = false;
StateHasChanged();
}
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
{
if (args.IsLabelMouseLeave)
{
if (args.AnchorId == _optionHelpAnchorId)
ScheduleClose();
_optionHelpControllerRef?.ScheduleCloseFromLabelLeave();
await InvokeAsync(StateHasChanged);
return;
}
_optionHelpKey = args.HelpKey;
_optionHelpTitle = args.OptionLabel;
_optionHelpAnchorId = args.AnchorId;
_showOptionHelp = true;
CancelCloseTimer();
await InvokeAsync(StateHasChanged);
}
private void CancelCloseTimer()
private void CloseReadonlyPopover()
{
_closeCts?.Cancel();
_closeCts = null;
}
private Task CancelCloseTimerAsync() { CancelCloseTimer(); return Task.CompletedTask; }
private void ScheduleClose()
{
_closeCts?.Cancel();
_closeCts = new CancellationTokenSource();
var token = _closeCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(300, token);
}
catch (OperationCanceledException) { return; }
await InvokeAsync(() =>
{
if (token.IsCancellationRequested) return;
_showOptionHelp = false;
_closeCts = null;
StateHasChanged();
});
}, token);
}
private Task ScheduleCloseAsync() { ScheduleClose(); return Task.CompletedTask; }
private void CloseOptionHelp()
{
CancelCloseTimer();
_showOptionHelp = false;
_showReadonlyPopover = false;
StateHasChanged();
}
private async Task HandleReadonlyBadgeClicked(ReadonlyBadgeClickedEventArgs args)
{
_readonlyPopoverControllerRef?.CancelCloseFromBadgeEnter();
_readonlyPopoverAnchorId = args.AnchorId;
_readonlyPopoverSource = args.Source;
_readonlyPopoverOptionName = args.OptionName;
_readonlyPopoverValue = args.Value;
_showReadonlyPopover = true;
CancelReadonlyCloseTimer();
await InvokeAsync(StateHasChanged);
}
private void CancelReadonlyCloseTimer()
{
_readonlyCloseCts?.Cancel();
_readonlyCloseCts = null;
}
private Task CancelReadonlyCloseTimerAsync() { CancelReadonlyCloseTimer(); return Task.CompletedTask; }
private void ScheduleReadonlyClose()
{
_readonlyCloseCts?.Cancel();
_readonlyCloseCts = new CancellationTokenSource();
var token = _readonlyCloseCts.Token;
_ = Task.Run(async () =>
{
try { await Task.Delay(300, token); }
catch (OperationCanceledException) { return; }
await InvokeAsync(() =>
{
if (token.IsCancellationRequested) return;
_showReadonlyPopover = false;
_readonlyCloseCts = null;
StateHasChanged();
});
}, token);
}
private Task ScheduleReadonlyCloseAsync() { ScheduleReadonlyClose(); return Task.CompletedTask; }
private void CloseReadonlyPopover()
{
CancelReadonlyCloseTimer();
_showReadonlyPopover = false;
StateHasChanged();
}
private void OnSectionToggle(string id)
{
if (_openPanels.Contains(id))
_openPanels.Remove(id);
else
_openPanels.Add(id);
StateHasChanged();
}
private void ExpandAll()
@@ -240,24 +134,15 @@
StateHasChanged();
}
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
{
if (string.IsNullOrEmpty(term)) return true;
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
}
private void EnterEditMode()
{
_isEditMode = true;
_activeFieldKey = null;
_pendingChanges.Clear();
Session.EnterEditMode();
StateHasChanged();
}
private async Task ExitEditModeWithConfirmAsync()
{
if (_pendingChanges.Count > 0)
if (Session.PendingChanges.Count > 0)
{
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
try
@@ -270,42 +155,34 @@
await module.DisposeAsync();
}
}
_pendingChanges.Clear();
_activeFieldKey = null;
_isEditMode = false;
Session.ExitEditModeDiscard();
StateHasChanged();
}
private void DeactivateActiveFieldIfClicked()
{
if (_isEditMode && _activeFieldKey != null)
if (Session.IsEditMode && Session.ActiveFieldKey != null)
{
_activeFieldKey = null;
Session.DeactivateField();
StateHasChanged();
}
}
private void ActivateField(string fieldKey)
{
_isEditMode = true;
_activeFieldKey = fieldKey;
Session.ActivateField(fieldKey);
StateHasChanged();
}
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
{
var existing = _pendingChanges.FirstOrDefault(c => c.SectionId == args.SectionId && c.OptionName == args.OptionName);
if (existing != null)
_pendingChanges.Remove(existing);
if (!Equals(args.OldValue, args.NewValue))
_pendingChanges.Add(new PendingEffectiveConfigChange(args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
_activeFieldKey = null;
Session.TrackCommit(args);
StateHasChanged();
}
private void OpenSaveModal()
{
if (_pendingChanges.Count == 0) return;
if (Session.PendingChanges.Count == 0) return;
_showSaveModal = true;
StateHasChanged();
}
@@ -315,33 +192,4 @@
_showSaveModal = false;
StateHasChanged();
}
private async Task ConfirmSave()
{
if (_pendingChanges.Count == 0) return;
var changes = _pendingChanges.ToList();
try
{
await ConfigService.ApplyEffectiveConfigChangesAsync(changes);
CloseSaveModal();
_pendingChanges.Clear();
_activeFieldKey = null;
_isEditMode = false;
StateHasChanged();
await OnSaveCompleted.InvokeAsync();
}
catch (Exception)
{
// TODO: show error to user (e.g. toast or modal message)
await InvokeAsync(StateHasChanged);
}
}
public ValueTask DisposeAsync()
{
_closeCts?.Cancel();
_closeCts?.Dispose();
_closeCts = null;
return ValueTask.CompletedTask;
}
}
@@ -13,7 +13,8 @@
overflow: visible;
}
.ec-toolbar {
/* Toolbar: single row, search left and actions right. Use ::deep so styles apply to EffectiveConfigToolbar (child) markup. */
::deep .ec-toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
@@ -22,14 +23,13 @@
min-width: 0;
}
/* Search uses available space; actions stay on the right */
.ec-toolbar-search {
::deep .ec-toolbar-search {
flex: 1 1 auto;
min-width: 0;
max-width: 24rem;
}
.ec-toolbar-actions {
::deep .ec-toolbar-actions {
display: flex;
flex-wrap: nowrap;
flex-shrink: 0;
@@ -19,10 +19,7 @@
IsActive="@(ActiveFieldKey == fieldKey)"
ActiveFieldKey="@ActiveFieldKey"
OnActivateEdit="@(() => OnActivateField.InvokeAsync(fieldKey))"
OnEditCommitted="@OnFieldEditCommitted"
OnReadonlyBadgeClicked="@OnReadonlyBadgeClicked"
OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave"
OnOptionHelpRequested="@OnOptionHelpRequested" />
OnEditCommitted="@OnFieldEditCommitted" />
}
</ul>
</div>
@@ -36,16 +33,12 @@
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<string> OnToggle { get; set; }
/// <summary>Raised when user requests option help (click or mouse leave from label). Passed through from Section.</summary>
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable.</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ActiveFieldKey { get; set; }
[Parameter] public EventCallback<string> OnActivateField { get; set; }
[Parameter] public EventCallback<EffectiveConfigEditCommittedArgs> OnFieldEditCommitted { get; set; }
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
}
@@ -0,0 +1,54 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
<div class="ec-toolbar @(IsEditMode ? "ec-edit-toolbar" : "")">
@if (ShowSearchBox)
{
<input type="search" id="ec-search-input" class="form-control form-control-sm ec-toolbar-search" placeholder="Search..." autocomplete="off"
value="@SearchTerm" @oninput="OnSearchInput" />
}
<div class="ec-toolbar-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnExpandAll" title="Expand all sections" aria-label="Expand all sections">
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
</button>
@if (IsEditMode)
{
<button type="button" class="btn btn-sm btn-primary" @onclick="OnOpenSaveModal" disabled="@(PendingChangesCount == 0)" title="Save changes to managed config" aria-label="Save">
<i class="bi bi-floppy-fill" aria-hidden="true"></i>
</button>
@if (PendingChangesCount > 0)
{
<span class="text-muted small">@PendingChangesCount change(s)</span>
}
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnExitEditModeWithConfirm" title="Exit edit mode" aria-label="Exit edit mode">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</button>
}
else
{
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnEnterEditMode" title="Edit effective config" aria-label="Edit">
<i class="bi bi-pencil" aria-hidden="true"></i>
</button>
}
</div>
</div>
@code {
[Parameter] public bool ShowSearchBox { get; set; } = true;
[Parameter] public string SearchTerm { get; set; } = "";
[Parameter] public EventCallback<string> SearchTermChanged { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public int PendingChangesCount { get; set; }
[Parameter] public EventCallback OnExpandAll { get; set; }
[Parameter] public EventCallback OnCollapseAll { get; set; }
[Parameter] public EventCallback OnEnterEditMode { get; set; }
[Parameter] public EventCallback OnExitEditModeWithConfirm { get; set; }
[Parameter] public EventCallback OnOpenSaveModal { get; set; }
private async Task OnSearchInput(ChangeEventArgs e)
{
await SearchTermChanged.InvokeAsync(e.Value?.ToString() ?? "");
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig;
/// <summary>
/// Base for effective-config custom value displays. Provides shared parameters,
/// <see cref="NotifyValueChanged"/>, and <see cref="GetEffectiveDisplayValue"/> so pending changes are shown before save.
/// </summary>
public abstract class EffectiveConfigValueDisplayBase : ComponentBase
{
[Parameter]
public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter]
public bool IsActiveEditor { get; set; }
[CascadingParameter]
public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter]
public EventCallback<object?> ValueChanged { get; set; }
[CascadingParameter]
public IEffectiveConfigEditSession? Session { get; set; }
protected Task NotifyValueChanged(object? value) => ValueChanged.InvokeAsync(value);
/// <summary>Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk).</summary>
protected object? GetEffectiveDisplayValue()
{
var pending = Session?.PendingChanges.FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
return pending != null ? pending.NewValue : Descriptor.GetValue();
}
}
@@ -0,0 +1,51 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<ConfigOptionHelpModal HelpKey="@HelpKey" AnchorId="@AnchorId" Visible="@Visible" Title="@Title"
OnClose="RequestClose" OnMouseEnterModal="CancelCloseTimer" OnMouseLeaveModal="ScheduleClose" />
@code {
[Parameter] public bool Visible { get; set; }
[Parameter] public string? HelpKey { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public string Title { get; set; } = "Option help";
[Parameter] public EventCallback OnCloseRequested { get; set; }
private CancellationTokenSource? _closeCts;
private void CancelCloseTimer()
{
_closeCts?.Cancel();
_closeCts = null;
}
private void ScheduleClose()
{
_closeCts?.Cancel();
_closeCts = new CancellationTokenSource();
var token = _closeCts.Token;
_ = Task.Run(async () =>
{
try { await Task.Delay(300, token); }
catch (OperationCanceledException) { return; }
await InvokeAsync(async () =>
{
if (token.IsCancellationRequested) return;
_closeCts = null;
await OnCloseRequested.InvokeAsync();
});
}, token);
}
private async Task RequestClose()
{
CancelCloseTimer();
await OnCloseRequested.InvokeAsync();
}
/// <summary>Called by the section when the label loses mouse (so we schedule close; if user moves to modal, OnMouseEnterModal cancels).</summary>
public void ScheduleCloseFromLabelLeave()
{
ScheduleClose();
}
}
@@ -0,0 +1,37 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<span id="@AnchorId" class="@CssClass" title="@Title" @onclick="OnClick" @onmouseleave="OnMouseLeave">
@ChildContent
</span>
@code {
[Parameter] public string AnchorId { get; set; } = "";
[Parameter] public string? HelpKey { get; set; }
[Parameter] public string OptionLabel { get; set; } = "";
[Parameter] public string? CssClass { get; set; }
[Parameter] public string? Title { get; set; }
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
private Task OnClick()
{
if (Ui == null || string.IsNullOrEmpty(HelpKey)) return Task.CompletedTask;
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
{
HelpKey = HelpKey,
OptionLabel = OptionLabel,
AnchorId = AnchorId
});
}
private Task OnMouseLeave()
{
if (Ui == null) return Task.CompletedTask;
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
{
AnchorId = AnchorId,
IsLabelMouseLeave = true
});
}
}
@@ -0,0 +1,40 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@if (Source?.IsReadOnly == true)
{
@if (IsInteractive && Ui != null)
{
<button type="button" id="@AnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())"
@onclick="OnClick" @onmouseleave="OnMouseLeave">
readonly
</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Source?.GetReadOnlyTooltip())">readonly</span>
}
}
@code {
[Parameter] public string AnchorId { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public object? Value { get; set; }
/// <summary>When true, render as button and raise click/mouseleave via context.</summary>
[Parameter] public bool IsInteractive { get; set; }
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
private Task OnClick()
{
if (Ui == null || Source == null) return Task.CompletedTask;
return Ui.RequestReadonlyPopoverAsync(new ReadonlyBadgeClickedEventArgs(AnchorId, Source, OptionName, Value));
}
private Task OnMouseLeave()
{
if (Ui == null) return Task.CompletedTask;
return Ui.ScheduleReadonlyPopoverCloseAsync();
}
}
@@ -1,7 +1,6 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@inject IJSRuntime JSRuntime
@if (Source is { IsReadOnly: true })
{
@@ -9,17 +8,15 @@
<span class="d-block">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</span>
@if (!string.IsNullOrEmpty(_removeCommand))
{
<div class="d-flex align-items-center gap-1 mt-1">
<code class="flex-grow-1 small text-break">@_removeCommand</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyRemoveCommand" title="Copy command">Copy</button>
<div class="mt-1">
<CopyableCodeLine Text="@_removeCommand" CssClass="small" CodeCssClass="small text-break" CopyTitle="Copy command" />
</div>
}
@if (!string.IsNullOrEmpty(_overrideLine))
{
<span class="d-block mt-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</span>
<div class="d-flex align-items-center gap-1 mt-1">
<code class="flex-grow-1 small">@_overrideLine</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyOverrideLine" title="Copy line">Copy</button>
<div class="mt-1">
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
</div>
}
</div>
@@ -47,30 +44,4 @@
_overrideLine = "";
}
}
private async Task CopyRemoveCommand()
{
if (string.IsNullOrEmpty(_removeCommand)) return;
try
{
await JSRuntime.InvokeVoidAsync("copyToClipboard", _removeCommand);
}
catch
{
// Copy not supported or failed
}
}
private async Task CopyOverrideLine()
{
if (string.IsNullOrEmpty(_overrideLine)) return;
try
{
await JSRuntime.InvokeVoidAsync("copyToClipboard", _overrideLine);
}
catch
{
// Copy not supported or failed
}
}
}
@@ -19,18 +19,14 @@
<p class="text-muted mb-2">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</p>
@if (!string.IsNullOrEmpty(_removeCommand))
{
<div class="d-flex align-items-center gap-1 mb-2">
<code class="flex-grow-1 text-break" style="font-size: 0.8rem;">@_removeCommand</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyRemoveCommand" title="Copy command">Copy</button>
<div class="mb-2">
<CopyableCodeLine Text="@_removeCommand" CodeCssClass="small" CopyTitle="Copy command" />
</div>
}
@if (!string.IsNullOrEmpty(_overrideLine))
{
<p class="text-muted mb-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</p>
<div class="d-flex align-items-center gap-1">
<code class="flex-grow-1" style="font-size: 0.8rem;">@_overrideLine</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyOverrideLine" title="Copy line">Copy</button>
</div>
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
}
</div>
</div>
@@ -48,6 +44,9 @@
[Parameter] public EventCallback OnMouseEnterPopover { get; set; }
[Parameter] public EventCallback OnMouseLeavePopover { get; set; }
private const string HiddenPositionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
private const string VisiblePositionStyleFormat = "position: fixed; top: {0}px; left: {1}px; visibility: visible;";
private string _positionStyle = "";
private readonly string _popoverId = "ec-readonly-popover-" + Guid.NewGuid().ToString("N")[..8];
private DotNetObjectReference<ReadonlyEditPopover>? _dotNetRef;
@@ -71,7 +70,7 @@
_overrideLine = "";
}
if (Visible && string.IsNullOrEmpty(_positionStyle))
_positionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
_positionStyle = HiddenPositionStyle;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -143,11 +142,11 @@
var gap = 4;
var topPx = r.Bottom + gap;
var leftPx = r.Left;
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
_positionStyle = string.Format(VisiblePositionStyleFormat, topPx, leftPx);
}
else
{
_positionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
_positionStyle = HiddenPositionStyle;
}
}
@@ -163,18 +162,6 @@
await OnMouseLeavePopover.InvokeAsync();
}
private async Task CopyRemoveCommand()
{
if (string.IsNullOrEmpty(_removeCommand)) return;
try { await JSRuntime.InvokeVoidAsync("copyToClipboard", _removeCommand); } catch { }
}
private async Task CopyOverrideLine()
{
if (string.IsNullOrEmpty(_overrideLine)) return;
try { await JSRuntime.InvokeVoidAsync("copyToClipboard", _overrideLine); } catch { }
}
private sealed record BoundingRect(
[property: JsonPropertyName("top")] double Top,
[property: JsonPropertyName("left")] double Left,
@@ -0,0 +1,64 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<ReadonlyEditPopover Visible="@Visible" AnchorId="@AnchorId" Source="@Source" OptionName="@OptionName" Value="@Value" ManagedFilePath="@ManagedFilePath"
OnClose="RequestClose" OnMouseEnterPopover="CancelCloseTimer" OnMouseLeavePopover="ScheduleClose" />
@code {
[Parameter] public bool Visible { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public object? Value { get; set; }
[Parameter] public string? ManagedFilePath { get; set; }
[Parameter] public EventCallback OnCloseRequested { get; set; }
private CancellationTokenSource? _closeCts;
private void CancelCloseTimer()
{
_closeCts?.Cancel();
_closeCts = null;
}
private void ScheduleClose()
{
_closeCts?.Cancel();
_closeCts = new CancellationTokenSource();
var token = _closeCts.Token;
_ = Task.Run(async () =>
{
try { await Task.Delay(300, token); }
catch (OperationCanceledException) { return; }
await InvokeAsync(async () =>
{
if (token.IsCancellationRequested) return;
_closeCts = null;
await OnCloseRequested.InvokeAsync();
});
}, token);
}
private async Task RequestClose()
{
CancelCloseTimer();
await OnCloseRequested.InvokeAsync();
}
/// <summary>
/// Called when mouse leaves readonly badge; close unless pointer enters popover quickly.
/// </summary>
public void ScheduleCloseFromBadgeLeave()
{
ScheduleClose();
}
/// <summary>
/// Called when readonly badge is interacted with to keep popover open.
/// </summary>
public void CancelCloseFromBadgeEnter()
{
CancelCloseTimer();
}
}
@@ -0,0 +1,24 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
/// <summary>
/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, and apply.
/// Components read state from here and call methods; the owning section re-renders after state changes.
/// </summary>
public interface IEffectiveConfigEditSession : IApplicationScopedService
{
bool IsEditMode { get; }
string? ActiveFieldKey { get; }
IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; }
void EnterEditMode();
void ExitEditModeDiscard();
void ActivateField(string fieldKey);
void DeactivateField();
void TrackCommit(EffectiveConfigEditCommittedArgs args);
void RevertChange(string sectionId, string optionName);
Task ApplyAsync(CancellationToken ct = default);
}
@@ -0,0 +1,72 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services;
public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
{
private readonly IDnsmasqConfigService _configService;
private readonly List<PendingEffectiveConfigChange> _pending = new();
public EffectiveConfigEditSession(IDnsmasqConfigService configService)
{
_configService = configService;
}
public bool IsEditMode { get; private set; }
public string? ActiveFieldKey { get; private set; }
public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges => _pending;
public void EnterEditMode()
{
IsEditMode = true;
ActiveFieldKey = null;
_pending.Clear();
}
public void ExitEditModeDiscard()
{
_pending.Clear();
ActiveFieldKey = null;
IsEditMode = false;
}
public void ActivateField(string fieldKey)
{
IsEditMode = true;
ActiveFieldKey = fieldKey;
}
public void DeactivateField()
{
ActiveFieldKey = null;
}
public void TrackCommit(EffectiveConfigEditCommittedArgs args)
{
var existing = _pending.FirstOrDefault(c =>
c.SectionId == args.SectionId && c.OptionName == args.OptionName);
if (existing != null)
_pending.Remove(existing);
if (!Equals(args.OldValue, args.NewValue))
_pending.Add(new PendingEffectiveConfigChange(
args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
ActiveFieldKey = null;
}
public void RevertChange(string sectionId, string optionName)
{
var existing = _pending.FirstOrDefault(c =>
c.SectionId == sectionId && c.OptionName == optionName);
if (existing != null)
_pending.Remove(existing);
}
public async Task ApplyAsync(CancellationToken ct = default)
{
if (_pending.Count == 0) return;
var changes = _pending.ToList();
await _configService.ApplyEffectiveConfigChangesAsync(changes, ct);
ExitEditModeDiscard();
}
}
@@ -0,0 +1,14 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// Cascaded context for effective-config UI intents. Children call these methods instead of
/// receiving 6–8 event callbacks; the section (or shell) provides the implementation.
/// </summary>
public sealed class EffectiveConfigUiContext
{
public required Func<ConfigOptionHelpRequestEventArgs, Task> RequestOptionHelpAsync { get; init; }
public required Func<ReadonlyBadgeClickedEventArgs, Task> RequestReadonlyPopoverAsync { get; init; }
public required Func<Task> ScheduleReadonlyPopoverCloseAsync { get; init; }
public required Func<string, Task> ActivateFieldAsync { get; init; }
public required Func<EffectiveConfigEditCommittedArgs, Task> CommitFieldAsync { get; init; }
}